diff --git a/deno.json b/deno.json index bae8fa257a9..565fbde562f 100644 --- a/deno.json +++ b/deno.json @@ -39,6 +39,8 @@ "exclude": ["**/*_test.*", "src/__OLD/**", "*.todo", "**/tests/**"] }, "imports": { + "@fresh/internal/test-utils": "./packages/internal/src/test-utils.ts", + "@fresh/internal/versions": "./packages/internal/src/versions.ts", "@deno/doc": "jsr:@deno/doc@^0.172.0", "@deno/esbuild-plugin": "jsr:@deno/esbuild-plugin@^1.2.0", "@fresh/build-id": "jsr:@fresh/build-id@^1.0.0", diff --git a/design/current-test-suite.md b/design/current-test-suite.md new file mode 100644 index 00000000000..2c8f74d8e93 --- /dev/null +++ b/design/current-test-suite.md @@ -0,0 +1,137 @@ +# Analysis of the Current Test Suite + +This document provides a detailed analysis of the existing test suite in the +Fresh repository. It outlines the architecture, identifies key problem areas, +and serves as a foundation for the proposed rewrite. + +## 1. Overview + +The test suite is spread across multiple packages, primarily `packages/fresh` +and `packages/plugin-vite`. It uses a combination of Deno's built-in test runner +(`Deno.test`), the standard library's `asserts` module, and a collection of +custom utility functions. + +The core of the testing strategy revolves around: + +- **Fixture-based testing:** Small, self-contained Fresh projects (`fixtures`) + are used to test specific features. +- **Child process execution:** Tests often spawn a Deno process to run a + development server (`deno run ... main.ts`). +- **E2E and DOM inspection:** A headless browser (`@astral/astral`) or a virtual + DOM (`linkedom`) is used to interact with the running server and assert the + state of the rendered HTML. + +## 2. Key Files and Components + +- Internal test utilities (canonical source): + - `@fresh/internal/test-utils` (from `packages/internal/src/*`) + - Provides shared helpers used across the monorepo: + - Browser helpers: `withBrowser`, `withBrowserApp` + - DOM helpers: `parseHtml`, `assertSelector`, `assertNotSelector`, + `assertMetaContent` + - Process helpers: `withChildProcessServer`, `getStdOutput` + - Server helpers: `FakeServer`, `serveMiddleware`, `MockBuildCache`, + `createFakeFs` + - FS helpers: `withTmpDir`, `writeFiles`, `delay`, `updateFile` + - Misc: `waitFor`, `usingEnv` + - `@fresh/internal/versions` + - Centralized version constants used in tests/docs. + +- `packages/fresh/tests/test_utils.tsx`: Now only contains Fresh-specific test + bits: + - JSX-based helpers used within Fresh tests (`Doc`, `charset`, `favicon`). + - Build and fixtures helpers (`buildProd`, `ALL_ISLAND_DIR`, + `ISLAND_GROUP_DIR`). + - All generic utilities were removed and should be imported from + `@fresh/internal/test-utils`. + +- `packages/plugin-vite/tests/test_utils.ts`: Thin wrappers for plugin-vite: + - Uses `@fresh/internal/test-utils` primitives under the hood. + - Adds Vite-specific helpers like `prepareDevServer`, `launchDevServer`, + `spawnDevServer`, and `buildVite`. + +- **Fixture Directories**: + - `packages/fresh/tests/fixtures_islands/` + - `packages/fresh/tests/fixture_island_groups/` + - `packages/plugin-vite/tests/fixtures/` + - These contain the small Fresh projects that are run during tests. + +## 3. Problematic Areas (pre-refactor) + +### 3.1. Temporary Directory Management + +- **The Problem:** The `prepareDevServer` function in + `plugin-vite/tests/test_utils.ts` calls `withTmpDir` from + `fresh/src/test_utils.ts`. The `withTmpDir` function is configured to create + temporary directories with a prefix like `tmp_vite_` **inside the + `packages/plugin-vite` directory itself**. +- **Impact:** This pollutes the project's source tree with temporary test + artifacts. This is messy for local development and can cause issues in CI + environments, as untracked files may be present during builds or other steps. + It also makes it harder to reason about the state of the repository. The user + has noted that moving this outside the repository breaks things due to path + dependencies (e.g., finding `deno.json`), indicating a tight coupling between + the test fixtures and the repository root. + +### 3.2. Redundant and Convoluted Logic + +- **The Problem:** There is significant overlap in the logic for starting a + server. `fresh` has `withChildProcessServer`, and `plugin-vite` has + `launchDevServer` and `spawnDevServer`, which are wrappers around + `withChildProcessServer`. This creates a confusing, multi-layered system. +- **Impact:** It's difficult for a developer to understand the exact sequence of + events when a test is run. Debugging is complicated because the root cause of + a failure could be in any of the layers of abstraction. The separation of + concerns is unclear. + +### 3.3. Brittle Server Readiness Check + +- **The Problem:** The `withChildProcessServer` function determines if the + server is ready by reading the process's `stdout` line by line and looking for + a URL (`http://...`). +- **Impact:** This is extremely fragile. It can fail if: + - The server's startup log message changes. + - The output is buffered differently across OSes or Deno versions. + - The log output is colored or contains other ANSI escape codes (though + `stripAnsiCode` is used, it's an extra step that can fail). + - The server logs an unrelated URL before it's actually ready to accept + connections. +- This is a likely source of the test flakiness observed across different + environments. + +### 3.4. Cross-Package Dependencies + +- Pre-refactor, `plugin-vite` imported helpers from + `fresh/tests/test_utils.tsx`. This tight coupling has been removed by + introducing `@fresh/internal/test-utils`. + +### 3.5. JSX in Utility Files + +- Generic utilities were moved out into `@fresh/internal/test-utils`. The + remaining `.tsx` file is intentionally limited to JSX-only test helpers used + by the Fresh tests (e.g., `Doc`, `favicon`). + +### 3.6. Lack of Abstraction + +- **The Problem:** The primitives of the test suite (creating a temporary + fixture, starting a server, running an E2E test) are not well-abstracted. They + are implemented as a series of standalone functions that are chained together + within the test files themselves. +- **Impact:** This leads to boilerplate code in the test files and makes it + difficult to see the "what" (the test's intent) because of all the "how" (the + setup and teardown mechanics). + +## 4. Current State (post-refactor) + +- Shared, generic test utilities live in `@fresh/internal/test-utils` inside + `packages/internal`. They are allowed to import Fresh internals as needed. +- Fresh-specific JSX helpers remain in `packages/fresh/tests/test_utils.tsx`. +- `packages/fresh/src/test_utils.ts` has been removed; runtime code no longer + imports test utilities. Where a fallback is needed (e.g., in `app.ts`), an + inline minimal `BuildCache` is used. +- `plugin-vite` tests consume shared primitives from + `@fresh/internal/test-utils` and wrap them with Vite-specific helpers where + appropriate. + +This layout removes cross-package coupling, eliminates duplicated helpers, and +keeps the repo green under `deno lint` and `deno check`. diff --git a/packages/fresh/src/app.ts b/packages/fresh/src/app.ts index b07a2a3eebe..17db0511004 100644 --- a/packages/fresh/src/app.ts +++ b/packages/fresh/src/app.ts @@ -8,6 +8,7 @@ import { runMiddlewares, } from "./middlewares/mod.ts"; import { Context } from "./context.ts"; +import type { ServerIslandRegistry } from "./context.ts"; import { mergePath, type Method, UrlPatternRouter } from "./router.ts"; import type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; import type { BuildCache } from "./build_cache.ts"; @@ -28,7 +29,48 @@ import { newNotFoundCmd, newRouteCmd, } from "./commands.ts"; -import { MockBuildCache } from "./test_utils.ts"; +// Minimal fallback BuildCache for handler() when no build cache is present in dev/test. +// +// Why this exists +// - In normal operation, a Builder populates a BuildCache and associates it +// with an App via setBuildCache(app, cache, mode). That cache powers features +// like island discovery, client entry resolution, entry assets, and the dev +// error overlay toggle. +// - In unit tests or simple dev scenarios where an App is constructed directly +// and no Builder is involved, getBuildCache() returns null. Historically some +// tests relied on a MockBuildCache from test utils. We removed that runtime +// dependency from this module, so we provide a tiny internal fallback here to +// keep App.handler() usable without requiring test utilities. +// +// Behavior +// - The fallback aims to be minimal and safe: it does not provide client assets +// or islands (no hydration). It only exposes empty registries/arrays and a +// features flag for the dev error overlay. This is sufficient for server-side +// tests and middleware behavior checks. +// - To preserve previous dev/test behavior, the error overlay is enabled when +// the app runs in "development" mode. In production with a deployment id +// (i.e. on Deploy), we never use this fallback (see handler()) and instead +// throw with guidance to run a build. +class __InlineFallbackBuildCache implements BuildCache { + root = ""; + clientEntry = ""; + islandRegistry: ServerIslandRegistry = new Map(); + features: { errorOverlay: boolean }; + + constructor(mode: "development" | "production" = "production") { + this.features = { errorOverlay: mode === "development" }; + } + + getEntryAssets(): string[] { + return []; + } + getFsRoutes(): Command[] { + return []; + } + readFile(): Promise { + return Promise.resolve(null); + } +} // TODO: Completed type clashes in older Deno versions // deno-lint-ignore no-explicit-any @@ -371,7 +413,9 @@ export class App { `Could not find _fresh directory. Maybe you forgot to run "deno task build" or maybe you're trying to run "main.ts" directly instead of "_fresh/server.js"?`, ); } else { - buildCache = new MockBuildCache([], this.config.mode); + // In dev/test fallback, enable error overlay feature to keep behavior + // consistent with MockBuildCache used in tests. + buildCache = new __InlineFallbackBuildCache(this.config.mode); } } diff --git a/packages/fresh/src/app_test.tsx b/packages/fresh/src/app_test.tsx index 12589ce7b9a..cddd57143cb 100644 --- a/packages/fresh/src/app_test.tsx +++ b/packages/fresh/src/app_test.tsx @@ -1,6 +1,6 @@ import { expect } from "@std/expect"; import { App } from "./app.ts"; -import { FakeServer } from "./test_utils.ts"; +import { FakeServer } from "@fresh/internal/test-utils"; import { HttpError } from "./error.ts"; Deno.test("App - .use()", async () => { diff --git a/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index de94d900e6a..29df9ae7cbe 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -2,9 +2,8 @@ import { expect } from "@std/expect"; import { Context } from "./context.ts"; import { App } from "fresh"; import { asset } from "fresh/runtime"; -import { FakeServer } from "./test_utils.ts"; +import { FakeServer, parseHtml } from "@fresh/internal/test-utils"; import { BUILD_ID } from "@fresh/build-id"; -import { parseHtml } from "../tests/test_utils.tsx"; Deno.test("FreshReqContext.prototype.redirect", () => { let res = Context.prototype.redirect("/"); diff --git a/packages/fresh/src/dev/builder_test.ts b/packages/fresh/src/dev/builder_test.ts index cd973646fc0..ff3e7ac7ae4 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -4,11 +4,12 @@ import { Builder, specToName } from "./builder.ts"; import { App } from "../app.ts"; import { DEV_ERROR_OVERLAY_URL } from "../constants.ts"; import { BUILD_ID } from "@fresh/build-id"; -import { withTmpDir, writeFiles } from "../test_utils.ts"; import { getStdOutput, withChildProcessServer, -} from "../../tests/test_utils.tsx"; + withTmpDir, + writeFiles, +} from "@fresh/internal/test-utils"; import { staticFiles } from "../middlewares/static_files.ts"; Deno.test({ diff --git a/packages/fresh/src/dev/dev_build_cache_test.ts b/packages/fresh/src/dev/dev_build_cache_test.ts index 64d5c6f6a0e..7698465f7aa 100644 --- a/packages/fresh/src/dev/dev_build_cache_test.ts +++ b/packages/fresh/src/dev/dev_build_cache_test.ts @@ -1,7 +1,7 @@ import { expect } from "@std/expect"; import { MemoryBuildCache } from "./dev_build_cache.ts"; import { FileTransformer } from "./file_transformer.ts"; -import { createFakeFs, withTmpDir } from "../test_utils.ts"; +import { createFakeFs, withTmpDir } from "@fresh/internal/test-utils"; import type { ResolvedBuildConfig } from "./builder.ts"; Deno.test({ diff --git a/packages/fresh/src/dev/file_transformer_test.ts b/packages/fresh/src/dev/file_transformer_test.ts index a86150e6d97..f22755c81d7 100644 --- a/packages/fresh/src/dev/file_transformer_test.ts +++ b/packages/fresh/src/dev/file_transformer_test.ts @@ -1,7 +1,7 @@ import { expect } from "@std/expect"; import type { FsAdapter } from "../fs.ts"; import { FileTransformer, type ProcessedFile } from "./file_transformer.ts"; -import { delay } from "../test_utils.ts"; +import { delay } from "@fresh/internal/test-utils"; function testTransformer(files: Record, root = "/") { const mockFs: FsAdapter = { diff --git a/packages/fresh/src/dev/fs_crawl_test.ts b/packages/fresh/src/dev/fs_crawl_test.ts index c848bca3cb6..ee6e55def07 100644 --- a/packages/fresh/src/dev/fs_crawl_test.ts +++ b/packages/fresh/src/dev/fs_crawl_test.ts @@ -1,5 +1,5 @@ import { expect } from "@std/expect/expect"; -import { createFakeFs } from "../test_utils.ts"; +import { createFakeFs } from "@fresh/internal/test-utils"; import { walkDir } from "./fs_crawl.ts"; Deno.test("walkDir - ", async () => { diff --git a/packages/fresh/src/dev/middlewares/error_overlay/middleware_test.tsx b/packages/fresh/src/dev/middlewares/error_overlay/middleware_test.tsx index 8e7a2d011be..0e21711121b 100644 --- a/packages/fresh/src/dev/middlewares/error_overlay/middleware_test.tsx +++ b/packages/fresh/src/dev/middlewares/error_overlay/middleware_test.tsx @@ -1,6 +1,6 @@ import { expect } from "@std/expect"; import { App } from "../../../app.ts"; -import { FakeServer } from "../../../test_utils.ts"; +import { FakeServer } from "@fresh/internal/test-utils"; import { devErrorOverlay } from "./middleware.tsx"; import { HttpError } from "../../../error.ts"; diff --git a/packages/fresh/src/dev/update_check_test.ts b/packages/fresh/src/dev/update_check_test.ts index e522f60ac8c..a8c972d746f 100644 --- a/packages/fresh/src/dev/update_check_test.ts +++ b/packages/fresh/src/dev/update_check_test.ts @@ -1,8 +1,7 @@ import * as path from "@std/path"; import denoJson from "../../deno.json" with { type: "json" }; -import { getStdOutput } from "../../tests/test_utils.tsx"; +import { getStdOutput, withTmpDir } from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; -import { withTmpDir } from "../test_utils.ts"; import type { CheckFile } from "./update_check.ts"; import { WEEK } from "../constants.ts"; import { retry } from "@std/async/retry"; diff --git a/packages/fresh/src/fs_routes_test.tsx b/packages/fresh/src/fs_routes_test.tsx index 1332d0da760..e169d2108e1 100644 --- a/packages/fresh/src/fs_routes_test.tsx +++ b/packages/fresh/src/fs_routes_test.tsx @@ -1,12 +1,16 @@ import { App, setBuildCache } from "./app.ts"; import { type FreshFsMod, sortRoutePaths } from "./fs_routes.ts"; -import { delay, FakeServer, MockBuildCache } from "./test_utils.ts"; -import { createFakeFs } from "./test_utils.ts"; +import { + createFakeFs, + delay, + FakeServer, + MockBuildCache, + parseHtml, +} from "@fresh/internal/test-utils"; import { expect, fn } from "@std/expect"; import { stub } from "@std/testing/mock"; import { type HandlerByMethod, type HandlerFn, page } from "./handlers.ts"; import type { Method } from "./router.ts"; -import { parseHtml } from "../tests/test_utils.tsx"; import type { Context } from "./context.ts"; import { HttpError } from "./error.ts"; import { crawlRouteDir } from "./dev/fs_crawl.ts"; diff --git a/packages/fresh/src/middlewares/mod_test.ts b/packages/fresh/src/middlewares/mod_test.ts index 77c44f1fd88..ce6efa75573 100644 --- a/packages/fresh/src/middlewares/mod_test.ts +++ b/packages/fresh/src/middlewares/mod_test.ts @@ -1,6 +1,6 @@ import { runMiddlewares } from "./mod.ts"; import { expect } from "@std/expect"; -import { serveMiddleware } from "../test_utils.ts"; +import { serveMiddleware } from "@fresh/internal/test-utils"; import type { Middleware } from "./mod.ts"; import type { Lazy, MaybeLazy } from "../types.ts"; diff --git a/packages/fresh/src/middlewares/static_files_test.ts b/packages/fresh/src/middlewares/static_files_test.ts index b50a8741e23..f86f4191a10 100644 --- a/packages/fresh/src/middlewares/static_files_test.ts +++ b/packages/fresh/src/middlewares/static_files_test.ts @@ -1,5 +1,5 @@ import { staticFiles } from "./static_files.ts"; -import { serveMiddleware } from "../test_utils.ts"; +import { serveMiddleware } from "@fresh/internal/test-utils"; import type { BuildCache, StaticFile } from "../build_cache.ts"; import { expect } from "@std/expect"; import { ASSET_CACHE_BUST_KEY } from "../constants.ts"; diff --git a/packages/fresh/src/middlewares/trailing_slashes_test.ts b/packages/fresh/src/middlewares/trailing_slashes_test.ts index b1c37e794bd..1e8779a0cc3 100644 --- a/packages/fresh/src/middlewares/trailing_slashes_test.ts +++ b/packages/fresh/src/middlewares/trailing_slashes_test.ts @@ -1,7 +1,7 @@ // deno-lint-ignore-file require-await import { trailingSlashes } from "./trailing_slashes.ts"; import { expect } from "@std/expect"; -import { serveMiddleware } from "../test_utils.ts"; +import { serveMiddleware } from "@fresh/internal/test-utils"; Deno.test("trailingSlashes - always", async () => { const middleware = trailingSlashes("always"); diff --git a/packages/fresh/tests/active_links_test.tsx b/packages/fresh/tests/active_links_test.tsx index 2d5262e8a07..ae1ec0e76f7 100644 --- a/packages/fresh/tests/active_links_test.tsx +++ b/packages/fresh/tests/active_links_test.tsx @@ -1,15 +1,12 @@ import { App, staticFiles } from "fresh"; import { - ALL_ISLAND_DIR, assertNotSelector, assertSelector, - buildProd, - Doc, + FakeServer, parseHtml, withBrowserApp, -} from "./test_utils.tsx"; - -import { FakeServer } from "../src/test_utils.ts"; +} from "@fresh/internal/test-utils"; +import { ALL_ISLAND_DIR, buildProd, Doc } from "./test_utils.tsx"; import { Partial } from "fresh/runtime"; const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); @@ -118,7 +115,7 @@ Deno.test({ return ctx.render(); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(`${address}/active_nav_partial`); let doc = parseHtml(await page.content()); diff --git a/packages/fresh/tests/doc_examples_test.tsx b/packages/fresh/tests/doc_examples_test.tsx index 1f6abc39962..18bcde06b29 100644 --- a/packages/fresh/tests/doc_examples_test.tsx +++ b/packages/fresh/tests/doc_examples_test.tsx @@ -1,12 +1,14 @@ -import twDenoJson from "../../plugin-tailwindcss/deno.json" with { - type: "json", -}; +// Versions and plugin-tailwind version are provided via @fresh/internal import * as Marked from "marked"; import { ensureDir, walk } from "@std/fs"; import { dirname, join, relative } from "@std/path"; // import { expect } from "@std/expect/expect"; -import { withTmpDir } from "../src/test_utils.ts"; -import { FRESH_VERSION, PREACT_VERSION } from "../../update/src/update.ts"; +import { withTmpDir } from "@fresh/internal/test-utils"; +import { + FRESH_VERSION, + PREACT_VERSION, + TAILWIND_PLUGIN_VERSION, +} from "@fresh/internal/versions"; Deno.test("Docs Code example checks", async () => { await using tmp = await withTmpDir(); @@ -22,9 +24,9 @@ Deno.test("Docs Code example checks", async () => { imports: { fresh: `jsr:@fresh/core@${FRESH_VERSION}`, "@fresh/plugin-tailwind-v3": - `jsr:@fresh/plugin-tailwind@^${twDenoJson.version}`, + `jsr:@fresh/plugin-tailwind@^${TAILWIND_PLUGIN_VERSION}`, "@fresh/plugin-tailwind": - `jsr:@fresh/plugin-tailwind@^${twDenoJson.version}`, + `jsr:@fresh/plugin-tailwind@^${TAILWIND_PLUGIN_VERSION}`, preact: `npm:preact@^${PREACT_VERSION}`, "@deno/gfm": "jsr:@deno/gfm@^0.11.0", "@std/expect": "jsr:@std/expect@^1.0.16", diff --git a/packages/fresh/tests/head_test.tsx b/packages/fresh/tests/head_test.tsx index b43356e893d..170b1a498df 100644 --- a/packages/fresh/tests/head_test.tsx +++ b/packages/fresh/tests/head_test.tsx @@ -1,13 +1,13 @@ import { App, staticFiles } from "fresh"; import { Head } from "fresh/runtime"; import { - buildProd, + FakeServer, parseHtml, waitFor, withBrowserApp, -} from "./test_utils.tsx"; +} from "@fresh/internal/test-utils"; +import { buildProd } from "./test_utils.tsx"; import { expect } from "@std/expect"; -import { FakeServer } from "../src/test_utils.ts"; import * as path from "@std/path"; Deno.test("Head - ssr - updates title", async () => { @@ -165,7 +165,7 @@ Deno.test({ applyCache(app); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(`${address}/title`); await page.locator(".ready").wait(); @@ -195,7 +195,7 @@ Deno.test({ applyCache(app); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(`${address}/meta`); await page.locator(".ready").wait(); @@ -240,7 +240,7 @@ Deno.test({ applyCache(app); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(`${address}/id`); await page.locator(".ready").wait(); @@ -285,7 +285,7 @@ Deno.test({ applyCache(app); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(`${address}/key`); await page.locator(".ready").wait(); diff --git a/packages/fresh/tests/islands_test.tsx b/packages/fresh/tests/islands_test.tsx index 3a364f36b8b..ac20b3551b7 100644 --- a/packages/fresh/tests/islands_test.tsx +++ b/packages/fresh/tests/islands_test.tsx @@ -11,21 +11,24 @@ import { JsxIsland } from "./fixtures_islands/JsxIsland.tsx"; import { JsxChildrenIsland } from "./fixtures_islands/JsxChildrenIsland.tsx"; import { NodeProcess } from "./fixtures_islands/NodeProcess.tsx"; import { signal } from "@preact/signals"; +import { + FakeServer, + parseHtml, + waitForText, + withBrowserApp, +} from "@fresh/internal/test-utils"; import { ALL_ISLAND_DIR, buildProd, Doc, ISLAND_GROUP_DIR, - withBrowserApp, } from "./test_utils.tsx"; -import { parseHtml, waitForText } from "./test_utils.tsx"; import { expect } from "@std/expect"; import { JsxConditional } from "./fixtures_islands/JsxConditional.tsx"; import { FnIsland } from "./fixtures_islands/FnIsland.tsx"; import { EscapeIsland } from "./fixtures_islands/EscapeIsland.tsx"; import type { FreshConfig } from "../src/config.ts"; import { FreshAttrs } from "./fixtures_islands/FreshAttrs.tsx"; -import { FakeServer } from "../src/test_utils.ts"; import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts"; import { ComputedSignal } from "./fixtures_islands/Computed.tsx"; import { EnvIsland } from "./fixtures_islands/EnvIsland.tsx"; @@ -68,7 +71,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); await page.locator(".increment").click(); @@ -90,7 +93,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator("#multiple-1.ready").wait(); await page.locator("#multiple-2.ready").wait(); @@ -116,7 +119,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator("#counter-1.ready").wait(); await page.locator("#counter-2.ready").wait(); @@ -141,7 +144,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator("#comp.ready").wait(); await page.locator("#comp .trigger").click(); @@ -162,7 +165,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator("pre").wait(); const text = await page @@ -186,7 +189,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); }); @@ -205,7 +208,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); await page.locator(".trigger").click(); @@ -229,7 +232,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -255,7 +258,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -281,7 +284,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -314,7 +317,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -346,7 +349,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -384,7 +387,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -419,7 +422,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -475,7 +478,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -516,7 +519,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -554,7 +557,7 @@ Deno.test({ ); }); - await withBrowserApp(app, async (page, address) => { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(address, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -572,7 +575,7 @@ Deno.test({ }); // Check escaping of ` { + await withBrowserApp(app.handler(), async (page, address) => { await page.goto(`${address}/foo`, { waitUntil: "load" }); await page.locator(".ready").wait(); @@ -589,7 +592,7 @@ Deno.test({ }); // Check escaping of ``) + "\n"; - } - - let out = space; - if (node instanceof HTMLElement || node instanceof HTMLMetaElement) { - out += colors.dim(colors.cyan("<")); - out += colors.cyan(node.localName); - - for (let i = 0; i < node.attributes.length; i++) { - const attr = node.attributes.item(i); - if (attr === null) continue; - out += " " + colors.yellow(attr.name); - out += colors.dim("="); - out += colors.green(`"${attr.value}"`); - } - - if (VOID_ELEMENTS.test(node.localName)) { - out += colors.dim(colors.cyan(">")) + "\n"; - return out; - } - - out += colors.dim(colors.cyan(">")); - if (node.childNodes.length) { - out += "\n"; - - for (let i = 0; i < node.childNodes.length; i++) { - const child = node.childNodes[i]; - out += _printDomNode(child, indent + 1); - } - - out += space; - } - - out += colors.dim(colors.cyan("")); - out += "\n"; - } - - return out; -} - -export interface TestDocument extends Document { - debug(): void; -} - -export function parseHtml(input: string): TestDocument { - // deno-lint-ignore no-explicit-any - const doc = new DOMParser().parseFromString(input, "text/html") as any; - Object.defineProperty(doc, "debug", { - // deno-lint-ignore no-console - value: () => console.log(prettyDom(doc)), - enumerable: false, - }); - return doc; -} - -export function assertSelector(doc: Document, selector: string) { - if (doc.querySelector(selector) === null) { - const html = prettyDom(doc); - throw new Error( - `Selector "${selector}" not found in document.\n\n${html}`, - ); - } -} - -export function assertNotSelector(doc: Document, selector: string) { - if (doc.querySelector(selector) !== null) { - const html = prettyDom(doc); - throw new Error( - `Selector "${selector}" found in document.\n\n${html}`, - ); - } -} - -export function assertMetaContent( - doc: Document, - nameOrProperty: string, - expected: string, -) { - let el = doc.querySelector(`meta[name="${nameOrProperty}"]`) as - | HTMLMetaElement - | null; - - if (el === null) { - el = doc.querySelector(`meta[property="${nameOrProperty}"]`) as - | HTMLMetaElement - | null; - } - - if (el === null) { - // deno-lint-ignore no-console - console.log(prettyDom(doc)); - throw new Error( - `No -tag found with content "${expected}"`, - ); - } - expect(el.content).toEqual(expected); -} - -export async function waitForText( - page: Page, - selector: string, - text: string, -) { - await page.waitForSelector(selector); - try { - await page.waitForFunction( - (sel: string, value: string) => { - const el = document.querySelector(sel); - if (el === null) return false; - return el.textContent === value; - }, - { args: [selector, String(text)] }, - ); - } catch (err) { - const body = await page.content(); - // deno-lint-ignore no-explicit-any - const pretty = prettyDom(parseHtml(body) as any); - - // deno-lint-ignore no-console - console.log( - `Text "${text}" not found on selector "${selector}" in html:\n\n${pretty}`, - ); - throw err; - } -} - -export async function waitFor( - fn: () => Promise | unknown, -): Promise { - let now = Date.now(); - const limit = now + 2000; - - while (now < limit) { - try { - if (await fn()) return; - } catch (err) { - if (now > limit) { - throw err; - } - } finally { - await new Promise((r) => setTimeout(r, 250)); - now = Date.now(); - } - } - - throw new Error(`Timed out`); -} - -export function getStdOutput( - out: Deno.CommandOutput, -): { stdout: string; stderr: string } { - const decoder = new TextDecoder(); - const stdout = colors.stripAnsiCode(decoder.decode(out.stdout)); - - const decoderErr = new TextDecoder(); - const stderr = colors.stripAnsiCode(decoderErr.decode(out.stderr)); - - return { stdout, stderr }; -} +// ---------------- Project-specific helpers below ---------------- const ISLAND_FIXTURE_DIR = path.join(import.meta.dirname!, "fixtures_islands"); const allIslandBuilder = new Builder({}); diff --git a/packages/init/src/init_test.ts b/packages/init/src/init_test.ts index b91a67ae4ad..54762cee910 100644 --- a/packages/init/src/init_test.ts +++ b/packages/init/src/init_test.ts @@ -7,10 +7,13 @@ import { initProject, } from "./init.ts"; import * as path from "@std/path"; -import { getStdOutput, withBrowser } from "../../fresh/tests/test_utils.tsx"; -import { waitForText } from "../../fresh/tests/test_utils.tsx"; -import { withChildProcessServer } from "../../fresh/tests/test_utils.tsx"; -import { withTmpDir as withTmpDirBase } from "../../fresh/src/test_utils.ts"; +import { + getStdOutput, + waitForText, + withBrowser, + withChildProcessServer, + withTmpDir as withTmpDirBase, +} from "@fresh/internal/test-utils"; import { stub } from "@std/testing/mock"; // deno-lint-ignore no-explicit-any diff --git a/packages/internal/README.md b/packages/internal/README.md new file mode 100644 index 00000000000..44363605015 --- /dev/null +++ b/packages/internal/README.md @@ -0,0 +1,8 @@ +# @fresh/internal + +Internal-only utilities and constants for the Fresh monorepo. + +- Test utilities: `@fresh/internal/test-utils` +- Version constants: `@fresh/internal/versions` + +Not published. Consumers outside this repo should not import these. diff --git a/packages/internal/deno.json b/packages/internal/deno.json new file mode 100644 index 00000000000..d5ec61acfb7 --- /dev/null +++ b/packages/internal/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@fresh/internal", + "exports": { + "./test-utils": "./src/test-utils.ts", + "./versions": "./src/versions.ts" + } +} diff --git a/packages/internal/src/browser.ts b/packages/internal/src/browser.ts new file mode 100644 index 00000000000..b93547f17a7 --- /dev/null +++ b/packages/internal/src/browser.ts @@ -0,0 +1,55 @@ +import { launch, type Page } from "@astral/astral"; + +const browser = await launch({ + args: [ + "--window-size=1280,720", + ...((Deno.env.get("CI") && Deno.build.os === "linux") + ? ["--no-sandbox"] + : []), + ], + headless: Deno.env.get("HEADLESS") !== "false", +}); + +export async function withBrowser( + fn: (page: Page) => void | Promise, +): Promise { + await using page = await browser.newPage(); + try { + await fn(page); + } catch (err) { + const raw = await page.content(); + // deno-lint-ignore no-console + console.log(raw); + throw err; + } +} + +type AppLike = { handler(): unknown }; + +function isAppLike(x: unknown): x is AppLike { + return typeof (x as AppLike)?.handler === "function"; +} + +export async function withBrowserApp( + appOrHandler: Deno.ServeHandler | AppLike, + fn: (page: Page, address: string) => void | Promise, +): Promise { + const handler = + (isAppLike(appOrHandler) + ? appOrHandler.handler() + : appOrHandler) as Deno.ServeHandler; + const aborter = new AbortController(); + await using server = Deno.serve({ + hostname: "localhost", + port: 0, + signal: aborter.signal, + onListen: () => {}, + }, handler); + + try { + await using page = await browser.newPage(); + await fn(page, `http://localhost:${server.addr.port}`); + } finally { + aborter.abort(); + } +} diff --git a/packages/internal/src/dom.ts b/packages/internal/src/dom.ts new file mode 100644 index 00000000000..a294c8aac58 --- /dev/null +++ b/packages/internal/src/dom.ts @@ -0,0 +1,159 @@ +import { DOMParser } from "linkedom"; +import * as colors from "@std/fmt/colors"; +import type { Page } from "@astral/astral"; + +export const VOID_ELEMENTS = + /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/; + +function _printDomNode(node: HTMLElement | Text | Node, indent: number) { + const space = " ".repeat(indent); + + if ((node as Node).nodeType === 3) { + return space + colors.dim((node as Text).textContent ?? "") + "\n"; + } else if ((node as Node).nodeType === 8) { + return space + colors.dim(`<--${(node as Text).data}-->`) + "\n"; + } + + let out = space; + if (node instanceof HTMLElement || node instanceof HTMLMetaElement) { + out += colors.dim(colors.cyan("<")); + out += colors.cyan(node.localName); + + for (let i = 0; i < node.attributes.length; i++) { + const attr = node.attributes.item(i); + if (attr === null) continue; + out += " " + colors.yellow(attr.name); + out += colors.dim("="); + out += colors.green(`"${attr.value}"`); + } + + if (VOID_ELEMENTS.test(node.localName)) { + out += colors.dim(colors.cyan(">")) + "\n"; + return out; + } + + out += colors.dim(colors.cyan(">")); + if (node.childNodes.length) { + out += "\n"; + + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i] as unknown as + | HTMLElement + | Text + | Node; + out += _printDomNode(child, indent + 1); + } + + out += space; + } + + out += colors.dim(colors.cyan("")); + out += "\n"; + } + + return out; +} + +function prettyDom(doc: Document) { + let out = colors.dim(`\n`); + const node = doc.documentElement as unknown as HTMLElement; + out += _printDomNode(node, 0); + return out; +} + +export interface TestDocument extends Document { + debug(): void; +} + +export function parseHtml(input: string): TestDocument { + // deno-lint-ignore no-explicit-any + const doc = new DOMParser().parseFromString(input, "text/html") as any; + Object.defineProperty(doc, "debug", { + // deno-lint-ignore no-console + value: () => console.log(prettyDom(doc)), + enumerable: false, + }); + return doc; +} + +export function assertSelector(doc: Document, selector: string) { + if (doc.querySelector(selector) === null) { + const html = prettyDom(doc); + throw new Error( + `Selector "${selector}" not found in document.\n\n${html}`, + ); + } +} + +export function assertNotSelector(doc: Document, selector: string) { + if (doc.querySelector(selector) !== null) { + const html = prettyDom(doc); + throw new Error( + `Selector "${selector}" found in document.\n\n${html}`, + ); + } +} + +/** + * Assert that a tag exists whose name or property equals `nameOrProperty`, + * and that its content equals `expected`. + */ +export function assertMetaContent( + doc: Document, + nameOrProperty: string, + expected: string, +): void { + let el = doc.querySelector(`meta[name="${nameOrProperty}"]`) as + | HTMLMetaElement + | null; + + if (el === null) { + el = doc.querySelector(`meta[property="${nameOrProperty}"]`) as + | HTMLMetaElement + | null; + } + + if (el === null) { + const html = prettyDom(doc); + throw new Error( + `No tag found with name/property "${nameOrProperty}".\n\n${html}`, + ); + } + + if (el.content !== expected) { + const html = prettyDom(doc); + throw new Error( + `Meta content mismatch for "${nameOrProperty}": expected "${expected}", got "${el.content}".\n\n${html}`, + ); + } +} + +export async function waitForText( + page: Page, + selector: string, + text: string, +) { + await page.waitForSelector(selector); + try { + await page.waitForFunction( + (sel: string, value: string) => { + const el = document.querySelector(sel); + if (el === null) return false; + return el.textContent === value; + }, + { args: [selector, String(text)] }, + ); + } catch (err) { + const body = await page.content(); + // deno-lint-ignore no-explicit-any + const pretty = prettyDom(parseHtml(body) as any); + + // deno-lint-ignore no-console + console.log( + `Text "${text}" not found on selector "${selector}" in html:\n\n${pretty}`, + ); + throw err; + } +} diff --git a/packages/internal/src/fs.ts b/packages/internal/src/fs.ts new file mode 100644 index 00000000000..90d6c9ae725 --- /dev/null +++ b/packages/internal/src/fs.ts @@ -0,0 +1,56 @@ +import * as path from "@std/path"; + +export async function writeFiles(dir: string, files: Record) { + const entries = Object.entries(files); + await Promise.all(entries.map(async (entry) => { + const [pathname, content] = entry; + const fullPath = path.join(dir, pathname); + try { + await Deno.mkdir(path.dirname(fullPath), { recursive: true }); + await Deno.writeTextFile(fullPath, content); + } catch (err) { + if (!(err instanceof Deno.errors.AlreadyExists)) { + throw err; + } + } + })); +} + +export const delay = (ms: number): Promise => + new Promise((r) => setTimeout(r, ms)); + +/** + * Update a text file and return an async disposable to restore its original content. + */ +export async function updateFile( + filePath: string, + fn: (text: string) => string | Promise, +): Promise { + const original = await Deno.readTextFile(filePath); + const result = await fn(original); + await Deno.writeTextFile(filePath, result); + + return { + async [Symbol.asyncDispose]() { + await Deno.writeTextFile(filePath, original); + }, + }; +} + +export async function withTmpDir( + options?: Deno.MakeTempOptions, +): Promise<{ dir: string } & AsyncDisposable> { + const dir = await Deno.makeTempDir(options); + return { + dir, + async [Symbol.asyncDispose]() { + if (Deno.env.get("CI") === "true") return; + try { + await Deno.remove(dir, { recursive: true }); + } catch { + // deno-lint-ignore no-console + console.warn(`Failed to clean up temp dir: "${dir}"`); + } + }, + }; +} diff --git a/packages/internal/src/process.ts b/packages/internal/src/process.ts new file mode 100644 index 00000000000..e7d53b51b37 --- /dev/null +++ b/packages/internal/src/process.ts @@ -0,0 +1,92 @@ +import * as colors from "@std/fmt/colors"; +import { TextLineStream } from "@std/streams/text-line-stream"; +import { mergeReadableStreams } from "@std/streams"; + +export interface TestChildServerOptions { + cwd: string; + args: string[]; + bin?: string; + env?: Record; +} + +export async function withChildProcessServer( + options: TestChildServerOptions, + fn: (address: string) => void | Promise, +) { + const aborter = new AbortController(); + const cp = await new Deno.Command(options.bin ?? Deno.execPath(), { + args: options.args, + stdin: "null", + stdout: "piped", + stderr: "piped", + cwd: options.cwd, + signal: aborter.signal, + env: options.env, + }).spawn(); + + const linesStdout: ReadableStream = cp.stdout + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + + const linesStderr: ReadableStream = cp.stderr + .pipeThrough(new TextDecoderStream()) + .pipeThrough(new TextLineStream()); + + const lines = mergeReadableStreams(linesStdout, linesStderr); + + const output: string[] = []; + let address = ""; + let found = false; + // @ts-ignore yes it does + for await (const raw of lines.values({ preventCancel: true })) { + const line = colors.stripAnsiCode(raw); + output.push(line); + const match = line.match( + /https?:\/\/[^:]+:\d+(\/\w+[-\w]*)*/g, + ); + if (match) { + address = match[0]; + found = true; + break; + } + } + + if (!found) { + // deno-lint-ignore no-console + console.log(output); + throw new Error(`Could not find server address`); + } + + let failed = false; + try { + await fn(address); + } catch (err) { + // deno-lint-ignore no-console + console.log(output); + failed = true; + throw err; + } finally { + aborter.abort(); + await cp.status; + for await (const line of lines) { + output.push(line); + } + + if (failed) { + // deno-lint-ignore no-console + console.log(output); + } + } +} + +export function getStdOutput( + out: Deno.CommandOutput, +): { stdout: string; stderr: string } { + const decoder = new TextDecoder(); + const stdout = colors.stripAnsiCode(decoder.decode(out.stdout)); + + const decoderErr = new TextDecoder(); + const stderr = colors.stripAnsiCode(decoderErr.decode(out.stderr)); + + return { stdout, stderr }; +} diff --git a/packages/fresh/src/test_utils.ts b/packages/internal/src/server.ts similarity index 66% rename from packages/fresh/src/test_utils.ts rename to packages/internal/src/server.ts index 70237b15447..bcdedb98784 100644 --- a/packages/fresh/src/test_utils.ts +++ b/packages/internal/src/server.ts @@ -1,12 +1,15 @@ -import { Context, type ServerIslandRegistry } from "./context.ts"; -import type { FsAdapter } from "./fs.ts"; -import type { BuildCache, StaticFile } from "./build_cache.ts"; -import type { ResolvedFreshConfig } from "./config.ts"; -import type { WalkEntry } from "@std/fs/walk"; -import { DEFAULT_CONN_INFO } from "./app.ts"; -import type { Command } from "./commands.ts"; -import { fsItemsToCommands, type FsRouteFile } from "./fs_routes.ts"; -import * as path from "@std/path"; +// Note: This internal test utility imports Fresh internals directly by path. +// This package is internal to the monorepo, so cross-package internal imports are acceptable here. +import type { ResolvedFreshConfig } from "../../fresh/src/config.ts"; +import { Context } from "../../fresh/src/context.ts"; +import { DEFAULT_CONN_INFO } from "../../fresh/src/app.ts"; +import type { BuildCache, StaticFile } from "../../fresh/src/build_cache.ts"; +import type { ServerIslandRegistry } from "../../fresh/src/context.ts"; +import type { Command } from "../../fresh/src/commands.ts"; +import { + fsItemsToCommands, + type FsRouteFile, +} from "../../fresh/src/fs_routes.ts"; const STUB = {} as unknown as Deno.ServeHandlerInfo; @@ -99,58 +102,6 @@ export function serveMiddleware( }); } -export function createFakeFs(files: Record): FsAdapter { - return { - cwd: () => ".", - async *walk(_root) { - for (const file of Object.keys(files)) { - const entry: WalkEntry = { - isDirectory: false, - isFile: true, - isSymlink: false, - name: file, - path: file, - }; - yield entry; - } - }, - // deno-lint-ignore require-await - async isDirectory(dir) { - return Object.keys(files).some((file) => file.startsWith(dir + "/")); - }, - async mkdirp(_dir: string) { - }, - readFile: Deno.readFile, - // deno-lint-ignore require-await - async readTextFile(path) { - return String(files[String(path)]); - }, - }; -} - -export const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export async function withTmpDir( - options?: Deno.MakeTempOptions, -): Promise<{ dir: string } & AsyncDisposable> { - const dir = await Deno.makeTempDir(options); - return { - dir, - async [Symbol.asyncDispose]() { - // Skip pointless cleanup in CI, speed up tests - if (Deno.env.get("CI") === "true") return; - - try { - await Deno.remove(dir, { recursive: true }); - } catch { - // Temp files are not cleaned up automatically on Windows - // deno-lint-ignore no-console - console.warn(`Failed to clean up temp dir: "${dir}"`); - } - }, - }; -} - export class MockBuildCache implements BuildCache { #files: FsRouteFile[]; root = ""; @@ -176,18 +127,43 @@ export class MockBuildCache implements BuildCache { } } -export async function writeFiles(dir: string, files: Record) { - const entries = Object.entries(files); - await Promise.all(entries.map(async (entry) => { - const [pathname, content] = entry; - const fullPath = path.join(dir, pathname); - try { - await Deno.mkdir(path.dirname(fullPath), { recursive: true }); - await Deno.writeTextFile(fullPath, content); - } catch (err) { - if (!(err instanceof Deno.errors.AlreadyExists)) { - throw err; +export function createFakeFs(files: Record): { + cwd: () => string; + walk: (_root: string) => AsyncGenerator<{ + isDirectory: boolean; + isFile: boolean; + isSymlink: boolean; + name: string; + path: string; + }>; + isDirectory: (dir: string) => Promise; + mkdirp: (_dir: string) => Promise; + readFile: typeof Deno.readFile; + readTextFile: (path: string) => Promise; +} { + return { + cwd: () => ".", + async *walk(_root: string) { + for (const file of Object.keys(files)) { + const entry = { + isDirectory: false, + isFile: true, + isSymlink: false, + name: file, + path: file, + }; + yield entry; } - } - })); + }, + // deno-lint-ignore require-await + async isDirectory(dir: string) { + return Object.keys(files).some((file) => file.startsWith(dir + "/")); + }, + async mkdirp(_dir: string) {}, + readFile: Deno.readFile, + // deno-lint-ignore require-await + async readTextFile(path: string) { + return String(files[String(path)]); + }, + }; } diff --git a/packages/internal/src/test-utils.ts b/packages/internal/src/test-utils.ts new file mode 100644 index 00000000000..e7403837c78 --- /dev/null +++ b/packages/internal/src/test-utils.ts @@ -0,0 +1,6 @@ +export * from "./process.ts"; +export * from "./browser.ts"; +export * from "./dom.ts"; +export * from "./fs.ts"; +export * from "./server.ts"; +export * from "./util.ts"; diff --git a/packages/internal/src/util.ts b/packages/internal/src/util.ts new file mode 100644 index 00000000000..b7dc7af621f --- /dev/null +++ b/packages/internal/src/util.ts @@ -0,0 +1,35 @@ +export async function waitFor( + fn: () => Promise | unknown, +): Promise { + let now = Date.now(); + const limit = now + 2000; + + while (now < limit) { + try { + if (await fn()) return; + } catch (err) { + if (now > limit) { + throw err; + } + } finally { + await new Promise((r) => setTimeout(r, 250)); + now = Date.now(); + } + } + + throw new Error(`Timed out`); +} + +export function usingEnv(name: string, value: string): Disposable { + const prev = Deno.env.get(name); + Deno.env.set(name, value); + return { + [Symbol.dispose]: () => { + if (prev === undefined) { + Deno.env.delete(name); + } else { + Deno.env.set(name, prev); + } + }, + }; +} diff --git a/packages/internal/src/versions.ts b/packages/internal/src/versions.ts new file mode 100644 index 00000000000..4eb48c0c921 --- /dev/null +++ b/packages/internal/src/versions.ts @@ -0,0 +1,8 @@ +export { FRESH_VERSION, PREACT_VERSION } from "../../update/src/update.ts"; + +// Read plugin-tailwindcss version from its deno.json +// This is safe as @fresh/internal is internal to the monorepo. +import twDenoJson from "../../plugin-tailwindcss/deno.json" with { + type: "json", +}; +export const TAILWIND_PLUGIN_VERSION: string = String(twDenoJson.version); diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index 5bc45208c5c..f04b5ed5da1 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -1,9 +1,5 @@ import { expect } from "@std/expect"; -import { - waitFor, - waitForText, - withBrowser, -} from "../../fresh/tests/test_utils.tsx"; +import { waitFor, waitForText, withBrowser } from "@fresh/internal/test-utils"; import { buildVite, DEMO_DIR, diff --git a/packages/plugin-vite/tests/dev_server_test.ts b/packages/plugin-vite/tests/dev_server_test.ts index 877778b9225..bf0678ec582 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -1,10 +1,6 @@ import * as path from "@std/path"; import { expect } from "@std/expect"; -import { - waitFor, - waitForText, - withBrowser, -} from "../../fresh/tests/test_utils.tsx"; +import { waitFor, waitForText, withBrowser } from "@fresh/internal/test-utils"; import { DEMO_DIR, FIXTURE_DIR, diff --git a/packages/plugin-vite/tests/test_utils.ts b/packages/plugin-vite/tests/test_utils.ts index 912367dca12..7432a8226a5 100644 --- a/packages/plugin-vite/tests/test_utils.ts +++ b/packages/plugin-vite/tests/test_utils.ts @@ -1,26 +1,17 @@ import { createBuilder } from "vite"; import * as path from "@std/path"; import { walk } from "@std/fs/walk"; -import { withTmpDir } from "../../fresh/src/test_utils.ts"; -import { withChildProcessServer } from "../../fresh/tests/test_utils.tsx"; +import { + updateFile, + usingEnv, + withChildProcessServer, + withTmpDir, +} from "@fresh/internal/test-utils"; export const DEMO_DIR = path.join(import.meta.dirname!, "..", "demo"); export const FIXTURE_DIR = path.join(import.meta.dirname!, "fixtures"); -export async function updateFile( - filePath: string, - fn: (text: string) => string | Promise, -) { - const original = await Deno.readTextFile(filePath); - const result = await fn(original); - await Deno.writeTextFile(filePath, result); - - return { - async [Symbol.asyncDispose]() { - await Deno.writeTextFile(filePath, original); - }, - }; -} +// updateFile is now provided by @fresh/internal/test-utils async function copyDir(from: string, to: string) { const entries = walk(from, { @@ -170,19 +161,7 @@ export async function buildVite( }; } -export function usingEnv(name: string, value: string) { - const prev = Deno.env.get(name); - Deno.env.set(name, value); - return { - [Symbol.dispose]: () => { - if (prev === undefined) { - Deno.env.delete(name); - } else { - Deno.env.set(name, prev); - } - }, - }; -} +export { updateFile, usingEnv }; export interface ProdOptions { cwd: string; diff --git a/packages/update/src/update_test.ts b/packages/update/src/update_test.ts index e2abdac761e..23273f84878 100644 --- a/packages/update/src/update_test.ts +++ b/packages/update/src/update_test.ts @@ -8,7 +8,7 @@ import { import { expect } from "@std/expect"; import { spy, type SpyCall } from "@std/testing/mock"; import { walk } from "@std/fs/walk"; -import { withTmpDir, writeFiles } from "../../fresh/src/test_utils.ts"; +import { withTmpDir, writeFiles } from "@fresh/internal/test-utils"; async function readFiles(dir: string): Promise> { const files: Record = {}; diff --git a/www/main_test.ts b/www/main_test.ts index 63bf9cb6800..504e44d44ae 100644 --- a/www/main_test.ts +++ b/www/main_test.ts @@ -1,7 +1,7 @@ import { withBrowser, withChildProcessServer, -} from "../packages/fresh/tests/test_utils.tsx"; +} from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; import { retry } from "@std/async/retry"; import {