From b1d8b0f8ec7f07dabcf6a5a35b9d6bafec96c744 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:35:25 +0200 Subject: [PATCH 01/14] docs: add design docs --- agents.md | 58 ++++++++++++++ design/current-test-suite.md | 75 ++++++++++++++++++ design/new-test-suite-proposal.md | 122 ++++++++++++++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 agents.md create mode 100644 design/current-test-suite.md create mode 100644 design/new-test-suite-proposal.md diff --git a/agents.md b/agents.md new file mode 100644 index 00000000000..0b19750b9fd --- /dev/null +++ b/agents.md @@ -0,0 +1,58 @@ +# Fresh + +Fresh is a next-generation web framework for Deno. It is designed to be fast, +reliable, and simple. + +Remember this is Fresh 2, which uses Vite by default. + +Please make yourself familiar with Fresh 2 and the changes compared to Fresh 1 here: https://fresh.deno.dev/docs/latest/examples/migration-guide + +## Project Overview + +This repository contains the source code for the Fresh framework, as well as the +official website for the project. The repository is a monorepo, with the +framework code located in the `packages` directory and the website code in the +`www` directory. + +The Fresh framework is built on top of Preact and uses a file-system-based +routing system. It also has built-in support for TypeScript and JSX. The +framework is designed to be as lightweight as possible, with a focus on +performance and ease of use. + +The website is a Fresh application that serves as the documentation and showcase +for the framework. It is a good example of how to build a real-world application +with Fresh. + +## Building and Running + +The project uses `deno task` for scripting. The following commands are +available: + +- `deno task test`: Runs the test suite. +- `deno task www`: Starts the development server for the website. +- `deno task build-www`: Builds the website for production. +- `deno task check:types`: Type-checks the code. +- `deno task ok`: Runs a comprehensive check that includes formatting, linting, + type-checking, and tests. + +## Development Conventions + +The project follows the standard Deno conventions. All code is written in +TypeScript and formatted with `deno fmt`. The project also uses `deno lint` to +enforce a consistent coding style. + +The project has a comprehensive test suite that is run on every commit. All new +features and bug fixes should be accompanied by tests. + +The project uses a monorepo structure, with each package located in its own +directory under the `packages` directory. The website is also a package, located +in the `www` directory. + +The project uses a file-system-based routing system. Each route is a file in the +`routes` directory. The file name determines the route's path. + +The project uses Preact for the view layer. All components are written in JSX. + +The project uses islands for client-side interactivity. An island is a component +that is rendered on the server and then hydrated on the client. This allows for +a fast first-page load, while still providing a rich interactive experience. diff --git a/design/current-test-suite.md b/design/current-test-suite.md new file mode 100644 index 00000000000..bd8ae629e58 --- /dev/null +++ b/design/current-test-suite.md @@ -0,0 +1,75 @@ +# 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 + +- **`packages/fresh/tests/test_utils.tsx`**: This is the main utility file for the `fresh` package tests. It contains functions for: + - Launching a headless browser (`withBrowser`, `withBrowserApp`). + - Spawning a child process for a dev server and waiting for it to be ready by parsing its stdout (`withChildProcessServer`). + - Building a Fresh application for production (`buildProd`). + - Parsing and asserting HTML content (`parseHtml`, `assertSelector`, etc.). + - It also contains JSX components (`Doc`, `favicon`) used for test setup, which is a problematic mixing of concerns. + +- **`packages/plugin-vite/tests/test_utils.ts`**: This file contains utilities specifically for testing the Vite plugin. It **re-imports and uses functions from `packages/fresh/tests/test_utils.tsx`**, notably `withChildProcessServer`. It also introduces its own logic for: + - Creating temporary directories for test fixtures (`prepareDevServer`). + - Copying fixture projects into these temporary directories. + - Launching and managing dev servers specifically for Vite-based projects (`launchDevServer`, `spawnDevServer`). + +- **`packages/fresh/src/test_utils.ts`**: This file contains lower-level utilities, including the `withTmpDir` function, which is the source of the temporary directory problem. + +- **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 + +### 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 + +- **The Problem:** `plugin-vite`'s test suite has a hard dependency on the test utilities of the `fresh` package (`import ... from "../../fresh/tests/test_utils.tsx"`). +- **Impact:** This is a significant architectural smell. Test utilities for one package should not be tightly coupled to another. It makes the packages harder to maintain and test in isolation. It also forces developers to understand the internals of two packages to work on the tests for one. + +### 3.5. JSX in Utility Files + +- **The Problem:** `packages/fresh/tests/test_utils.tsx` is a `.tsx` file and contains JSX components (`Doc`, `favicon`). +- **Impact:** This mixes test infrastructure logic with view-layer components. Test utilities should be pure logic (TypeScript modules) and should not be concerned with rendering. This makes the file harder to read and maintain, and it creates an unnecessary dependency on Preact/JSX for what should be a generic utility module. + +### 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. Conclusion + +The current test suite is functional but suffers from several major architectural flaws that make it brittle, difficult to maintain, and hard to reason about. The duplication of logic, tight coupling between packages, and fragile implementation details (like stdout scraping and in-repository temporary folders) are the primary sources of the pain points described by the user. A significant refactoring is required to address these issues and create a robust and scalable testing framework for the Fresh ecosystem. diff --git a/design/new-test-suite-proposal.md b/design/new-test-suite-proposal.md new file mode 100644 index 00000000000..f7c5f004d6d --- /dev/null +++ b/design/new-test-suite-proposal.md @@ -0,0 +1,122 @@ +# Proposal for a New Test Suite Architecture + +This document outlines a plan to refactor the Fresh test suite to address the problems identified in the analysis document. The goal is to create a robust, maintainable, and easy-to-understand testing framework. + +## 1. Guiding Principles + +- **Centralization:** Shared testing logic should live in one place. +- **Abstraction:** Hide implementation details, expose clear intent. +- **Robustness:** Eliminate fragile patterns like stdout scraping. +- **Isolation:** Packages should not have test-level dependencies on each other. +- **Clarity:** Code should be easy to read and reason about. + +## 2. The `internal` Package + +I will create a new package at `packages/internal`. This package will **not** be published to `deno.land/x` and will serve as a centralized home for all shared testing utilities. + +### 2.1. Directory Structure + +``` +packages/ +└── internal/ + ├── deno.json + ├── README.md + ├── src/ + │ ├── server.ts # Test server management class (TestServer) + │ ├── fixture.ts # Fixture management utilities + │ ├── browser.ts # Headless browser utilities + │ └── asserts.ts # Common assertions (DOM, etc.) + └── fixtures/ # (Optional) Truly shared test fixtures +``` + +### 2.2. Dependencies + +The `deno.json` for `packages/internal` will explicitly list all testing-related dependencies (`@astral/astral`, `linkedom`, `@std/expect`, etc.). Other packages will then have a development-only dependency on the `internal` package via a `file:` reference in their `deno.json`. + +## 3. Core Abstractions + +Instead of a collection of disconnected functions, we will introduce classes and well-defined interfaces to manage the testing lifecycle. + +### 3.1. `TestServer` Class (`packages/internal/src/server.ts`) + +This class will be the cornerstone of the new architecture. It will encapsulate all the logic related to starting, managing, and stopping a test server. + +**Proposed `TestServer` API:** + +```typescript +interface TestServerOptions { + fixture: string; // Path to the fixture directory + // ... other options like env vars +} + +class TestServer implements AsyncDisposable { + readonly address: string; // The server's base URL + readonly projectRoot: string; // The temporary directory for the fixture + + constructor(options: TestServerOptions); + + static async create(options: TestServerOptions): Promise; + + // For simple fetch-based tests + fetch(path: string, init?: RequestInit): Promise; + + // For E2E tests + async withPage(fn: (page: Page) => Promise): Promise; + + // To stop the server + async [Symbol.asyncDispose](): Promise; +} +``` + +**Key Improvements:** + +1. **Robust Readiness Check:** The `TestServer.create` factory method will implement a robust health-check loop. It will repeatedly attempt to `fetch` a well-known endpoint (e.g., `/`) on the server until it receives a successful response or times out. This completely eliminates the fragile stdout scraping. +2. **Unified Interface:** It provides a single, clear entry point for running any kind of test against a server, whether it's a simple `fetch` or a full-blown browser test. +3. **Resource Management:** By implementing `AsyncDisposable`, we can use the `await using` syntax in tests to guarantee that the server process and temporary directories are cleaned up, even if the test fails. + +### 3.2. Fixture Management (`packages/internal/src/fixture.ts`) + +This module will handle the creation and management of temporary project directories for tests. + +**The Temporary Directory Problem:** + +The core issue is that fixtures need access to the root `deno.json` and potentially other files to resolve dependencies correctly. Simply copying a fixture to `/tmp` breaks these relative paths. + +**Proposed Solution:** + +1. **Default to OS Temp:** The new `createTemporaryFixture` function will, by default, create temporary directories in the OS's standard temp location (e.g., `Deno.makeTempDir()`). +2. **Intelligent Copying/Symlinking:** To solve the dependency issue, the function will: + a. Copy the specific fixture files (e.g., from `packages/plugin-vite/tests/fixtures/my-app`) into the temporary directory. + b. Create a **symlink** from `deno.json` in the temporary directory back to the repository's root `deno.json` (`ln -s /path/to/repo/deno.json /tmp/test-123/deno.json`). + c. This approach keeps the test environment isolated while ensuring that Deno's dependency resolution works as expected. We can extend this to symlink other necessary root-level files or directories if needed. + +This strategy provides the best of both worlds: clean, isolated test runs outside the project tree, without breaking the build tooling. + +## 4. Refactoring Plan + +1. **Phase 1: Create the `internal` Package** + - Set up the directory structure for `packages/internal`. + - Move all testing dependencies into its `deno.json`. + - Create the initial `TestServer` class and fixture management utilities, implementing the robust health check and temporary directory strategies. + +2. **Phase 2: Refactor `packages/plugin-vite` Tests** + - Update `packages/plugin-vite/deno.json` to depend on the local `internal` package. + - Rewrite `packages/plugin-vite/tests/test_utils.ts` to be a thin wrapper, if needed at all. Most logic should be imported directly from `internal`. + - Refactor the actual tests (e.g., `dev_server_test.ts`) to use the new `TestServer` class (`await using server = await TestServer.create(...)`). + - This will eliminate the cross-package import to `fresh` and the in-repository temporary folders. + +3. **Phase 3: Refactor `packages/fresh` Tests** + - Update `packages/fresh/deno.json` to depend on the `internal` package. + - Delete `packages/fresh/tests/test_utils.tsx`. + - Move any reusable, non-JSX utility functions (like `parseHtml`, `assertSelector`) to `packages/internal/src/asserts.ts`. + - Move the JSX helper components (`Doc`, `favicon`) into a dedicated file within the `packages/fresh/tests` directory, but **not** a general-purpose utility file. They are specific to the tests that use them. + - Refactor the tests (e.g., `islands_test.tsx`) to use the `TestServer` from the `internal` package. + +4. **Phase 4: Fixture Consolidation** + - Analyze all fixtures across all packages. + - If any fixture is used by tests in more than one package, move it to `packages/internal/fixtures`. + - Fixtures used only by a single package will remain in that package's test directory. + +## 5. Conclusion + +This plan systematically dismantles the current tangled test suite and replaces it with a modern, centralized, and robust architecture. By introducing the `internal` package and the `TestServer` abstraction, we can eliminate redundant code, fix brittle implementation details, and make the entire testing process easier to understand and maintain. The proposed solution for the temporary directory problem provides a path to clean up the repository without breaking the underlying build and dependency resolution mechanisms. From 05e6a4307390c6ed467e5cc4cff86f19246c0694 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 20:14:24 +0200 Subject: [PATCH 02/14] test: refactor snapshot #1 --- agents.md | 3 +- deno.json | 1 + deno.lock | 14 ++ design/current-test-suite.md | 139 ++++++++++---- design/new-test-suite-proposal.md | 125 ++++++++----- packages/fresh/src/context_test.tsx | 2 +- packages/fresh/src/dev/builder_test.ts | 5 +- packages/fresh/src/dev/update_check_test.ts | 2 +- packages/fresh/src/fs_routes_test.tsx | 2 +- packages/fresh/tests/active_links_test.tsx | 11 +- packages/fresh/tests/doc_examples_test.tsx | 2 +- packages/fresh/tests/head_test.tsx | 18 +- packages/fresh/tests/islands_test.tsx | 57 +++--- packages/fresh/tests/partials_test.tsx | 112 ++++++------ packages/init/src/init_test.ts | 11 +- packages/plugin-vite/tests/build_test.ts | 6 +- packages/plugin-vite/tests/dev_server_test.ts | 6 +- packages/plugin-vite/tests/test_utils.ts | 21 +-- packages/test-utils/README.md | 17 ++ packages/test-utils/deno.json | 25 +++ packages/test-utils/src/browser.ts | 55 ++++++ packages/test-utils/src/dom.ts | 125 +++++++++++++ packages/test-utils/src/fs.ts | 38 ++++ packages/test-utils/src/mod.ts | 6 + packages/test-utils/src/process.ts | 92 ++++++++++ packages/test-utils/src/server.ts | 169 ++++++++++++++++++ packages/test-utils/src/util.ts | 35 ++++ packages/update/src/update_test.ts | 2 +- www/main_test.ts | 5 +- 29 files changed, 882 insertions(+), 224 deletions(-) create mode 100644 packages/test-utils/README.md create mode 100644 packages/test-utils/deno.json create mode 100644 packages/test-utils/src/browser.ts create mode 100644 packages/test-utils/src/dom.ts create mode 100644 packages/test-utils/src/fs.ts create mode 100644 packages/test-utils/src/mod.ts create mode 100644 packages/test-utils/src/process.ts create mode 100644 packages/test-utils/src/server.ts create mode 100644 packages/test-utils/src/util.ts diff --git a/agents.md b/agents.md index 0b19750b9fd..01eb41b22ab 100644 --- a/agents.md +++ b/agents.md @@ -5,7 +5,8 @@ reliable, and simple. Remember this is Fresh 2, which uses Vite by default. -Please make yourself familiar with Fresh 2 and the changes compared to Fresh 1 here: https://fresh.deno.dev/docs/latest/examples/migration-guide +Please make yourself familiar with Fresh 2 and the changes compared to Fresh 1 +here: https://fresh.deno.dev/docs/latest/examples/migration-guide ## Project Overview diff --git a/deno.json b/deno.json index bae8fa257a9..b2b9523e11b 100644 --- a/deno.json +++ b/deno.json @@ -39,6 +39,7 @@ "exclude": ["**/*_test.*", "src/__OLD/**", "*.todo", "**/tests/**"] }, "imports": { + "@fresh/test-utils": "./packages/test-utils/src/mod.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/deno.lock b/deno.lock index ea888810b63..a6d1393485c 100644 --- a/deno.lock +++ b/deno.lock @@ -3750,6 +3750,20 @@ "npm:vite@^7.1.4" ] }, + "packages/test-utils": { + "dependencies": [ + "jsr:@astral/astral@~0.5.3", + "jsr:@fresh/core@2", + "jsr:@std/assert@^1.0.8", + "jsr:@std/async@^1.0.13", + "jsr:@std/expect@^1.0.16", + "jsr:@std/fmt@^1.0.8", + "jsr:@std/fs@1", + "jsr:@std/path@1", + "jsr:@std/streams@1", + "npm:linkedom@~0.18.10" + ] + }, "www": { "dependencies": [ "npm:@tailwindcss/vite@^4.1.12", diff --git a/design/current-test-suite.md b/design/current-test-suite.md index bd8ae629e58..57096ed3227 100644 --- a/design/current-test-suite.md +++ b/design/current-test-suite.md @@ -1,75 +1,136 @@ # 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. +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 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 - -- **`packages/fresh/tests/test_utils.tsx`**: This is the main utility file for the `fresh` package tests. It contains functions for: - - Launching a headless browser (`withBrowser`, `withBrowserApp`). - - Spawning a child process for a dev server and waiting for it to be ready by parsing its stdout (`withChildProcessServer`). - - Building a Fresh application for production (`buildProd`). - - Parsing and asserting HTML content (`parseHtml`, `assertSelector`, etc.). - - It also contains JSX components (`Doc`, `favicon`) used for test setup, which is a problematic mixing of concerns. +- **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. -- **`packages/plugin-vite/tests/test_utils.ts`**: This file contains utilities specifically for testing the Vite plugin. It **re-imports and uses functions from `packages/fresh/tests/test_utils.tsx`**, notably `withChildProcessServer`. It also introduces its own logic for: - - Creating temporary directories for test fixtures (`prepareDevServer`). - - Copying fixture projects into these temporary directories. - - Launching and managing dev servers specifically for Vite-based projects (`launchDevServer`, `spawnDevServer`). +## 2. Key Files and Components -- **`packages/fresh/src/test_utils.ts`**: This file contains lower-level utilities, including the `withTmpDir` function, which is the source of the temporary directory problem. +- **`packages/fresh/tests/test_utils.tsx`**: This is the main utility file for + the `fresh` package tests. It contains functions for: + - Launching a headless browser (`withBrowser`, `withBrowserApp`). + - Spawning a child process for a dev server and waiting for it to be ready by + parsing its stdout (`withChildProcessServer`). + - Building a Fresh application for production (`buildProd`). + - Parsing and asserting HTML content (`parseHtml`, `assertSelector`, etc.). + - It also contains JSX components (`Doc`, `favicon`) used for test setup, + which is a problematic mixing of concerns. + +- **`packages/plugin-vite/tests/test_utils.ts`**: This file contains utilities + specifically for testing the Vite plugin. It **re-imports and uses functions + from `packages/fresh/tests/test_utils.tsx`**, notably + `withChildProcessServer`. It also introduces its own logic for: + - Creating temporary directories for test fixtures (`prepareDevServer`). + - Copying fixture projects into these temporary directories. + - Launching and managing dev servers specifically for Vite-based projects + (`launchDevServer`, `spawnDevServer`). + +- **`packages/fresh/src/test_utils.ts`**: This file contains lower-level + utilities, including the `withTmpDir` function, which is the source of the + temporary directory problem. - **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. + - `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 ### 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. +- **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. +- **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://...`). +- **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. + - 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 -- **The Problem:** `plugin-vite`'s test suite has a hard dependency on the test utilities of the `fresh` package (`import ... from "../../fresh/tests/test_utils.tsx"`). -- **Impact:** This is a significant architectural smell. Test utilities for one package should not be tightly coupled to another. It makes the packages harder to maintain and test in isolation. It also forces developers to understand the internals of two packages to work on the tests for one. +- **The Problem:** `plugin-vite`'s test suite has a hard dependency on the test + utilities of the `fresh` package + (`import ... from "../../fresh/tests/test_utils.tsx"`). +- **Impact:** This is a significant architectural smell. Test utilities for one + package should not be tightly coupled to another. It makes the packages harder + to maintain and test in isolation. It also forces developers to understand the + internals of two packages to work on the tests for one. ### 3.5. JSX in Utility Files -- **The Problem:** `packages/fresh/tests/test_utils.tsx` is a `.tsx` file and contains JSX components (`Doc`, `favicon`). -- **Impact:** This mixes test infrastructure logic with view-layer components. Test utilities should be pure logic (TypeScript modules) and should not be concerned with rendering. This makes the file harder to read and maintain, and it creates an unnecessary dependency on Preact/JSX for what should be a generic utility module. +- **The Problem:** `packages/fresh/tests/test_utils.tsx` is a `.tsx` file and + contains JSX components (`Doc`, `favicon`). +- **Impact:** This mixes test infrastructure logic with view-layer components. + Test utilities should be pure logic (TypeScript modules) and should not be + concerned with rendering. This makes the file harder to read and maintain, and + it creates an unnecessary dependency on Preact/JSX for what should be a + generic utility module. ### 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). +- **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. Conclusion -The current test suite is functional but suffers from several major architectural flaws that make it brittle, difficult to maintain, and hard to reason about. The duplication of logic, tight coupling between packages, and fragile implementation details (like stdout scraping and in-repository temporary folders) are the primary sources of the pain points described by the user. A significant refactoring is required to address these issues and create a robust and scalable testing framework for the Fresh ecosystem. +The current test suite is functional but suffers from several major +architectural flaws that make it brittle, difficult to maintain, and hard to +reason about. The duplication of logic, tight coupling between packages, and +fragile implementation details (like stdout scraping and in-repository temporary +folders) are the primary sources of the pain points described by the user. A +significant refactoring is required to address these issues and create a robust +and scalable testing framework for the Fresh ecosystem. diff --git a/design/new-test-suite-proposal.md b/design/new-test-suite-proposal.md index f7c5f004d6d..007f601ff85 100644 --- a/design/new-test-suite-proposal.md +++ b/design/new-test-suite-proposal.md @@ -1,6 +1,8 @@ # Proposal for a New Test Suite Architecture -This document outlines a plan to refactor the Fresh test suite to address the problems identified in the analysis document. The goal is to create a robust, maintainable, and easy-to-understand testing framework. +This document outlines a plan to refactor the Fresh test suite to address the +problems identified in the analysis document. The goal is to create a robust, +maintainable, and easy-to-understand testing framework. ## 1. Guiding Principles @@ -12,7 +14,9 @@ This document outlines a plan to refactor the Fresh test suite to address the pr ## 2. The `internal` Package -I will create a new package at `packages/internal`. This package will **not** be published to `deno.land/x` and will serve as a centralized home for all shared testing utilities. +I will create a new package at `packages/internal`. This package will **not** be +published to `deno.land/x` and will serve as a centralized home for all shared +testing utilities. ### 2.1. Directory Structure @@ -31,15 +35,20 @@ packages/ ### 2.2. Dependencies -The `deno.json` for `packages/internal` will explicitly list all testing-related dependencies (`@astral/astral`, `linkedom`, `@std/expect`, etc.). Other packages will then have a development-only dependency on the `internal` package via a `file:` reference in their `deno.json`. +The `deno.json` for `packages/internal` will explicitly list all testing-related +dependencies (`@astral/astral`, `linkedom`, `@std/expect`, etc.). Other packages +will then have a development-only dependency on the `internal` package via a +`file:` reference in their `deno.json`. ## 3. Core Abstractions -Instead of a collection of disconnected functions, we will introduce classes and well-defined interfaces to manage the testing lifecycle. +Instead of a collection of disconnected functions, we will introduce classes and +well-defined interfaces to manage the testing lifecycle. ### 3.1. `TestServer` Class (`packages/internal/src/server.ts`) -This class will be the cornerstone of the new architecture. It will encapsulate all the logic related to starting, managing, and stopping a test server. +This class will be the cornerstone of the new architecture. It will encapsulate +all the logic related to starting, managing, and stopping a test server. **Proposed `TestServer` API:** @@ -70,53 +79,89 @@ class TestServer implements AsyncDisposable { **Key Improvements:** -1. **Robust Readiness Check:** The `TestServer.create` factory method will implement a robust health-check loop. It will repeatedly attempt to `fetch` a well-known endpoint (e.g., `/`) on the server until it receives a successful response or times out. This completely eliminates the fragile stdout scraping. -2. **Unified Interface:** It provides a single, clear entry point for running any kind of test against a server, whether it's a simple `fetch` or a full-blown browser test. -3. **Resource Management:** By implementing `AsyncDisposable`, we can use the `await using` syntax in tests to guarantee that the server process and temporary directories are cleaned up, even if the test fails. +1. **Robust Readiness Check:** The `TestServer.create` factory method will + implement a robust health-check loop. It will repeatedly attempt to `fetch` a + well-known endpoint (e.g., `/`) on the server until it receives a successful + response or times out. This completely eliminates the fragile stdout + scraping. +2. **Unified Interface:** It provides a single, clear entry point for running + any kind of test against a server, whether it's a simple `fetch` or a + full-blown browser test. +3. **Resource Management:** By implementing `AsyncDisposable`, we can use the + `await using` syntax in tests to guarantee that the server process and + temporary directories are cleaned up, even if the test fails. ### 3.2. Fixture Management (`packages/internal/src/fixture.ts`) -This module will handle the creation and management of temporary project directories for tests. +This module will handle the creation and management of temporary project +directories for tests. **The Temporary Directory Problem:** -The core issue is that fixtures need access to the root `deno.json` and potentially other files to resolve dependencies correctly. Simply copying a fixture to `/tmp` breaks these relative paths. +The core issue is that fixtures need access to the root `deno.json` and +potentially other files to resolve dependencies correctly. Simply copying a +fixture to `/tmp` breaks these relative paths. **Proposed Solution:** -1. **Default to OS Temp:** The new `createTemporaryFixture` function will, by default, create temporary directories in the OS's standard temp location (e.g., `Deno.makeTempDir()`). -2. **Intelligent Copying/Symlinking:** To solve the dependency issue, the function will: - a. Copy the specific fixture files (e.g., from `packages/plugin-vite/tests/fixtures/my-app`) into the temporary directory. - b. Create a **symlink** from `deno.json` in the temporary directory back to the repository's root `deno.json` (`ln -s /path/to/repo/deno.json /tmp/test-123/deno.json`). - c. This approach keeps the test environment isolated while ensuring that Deno's dependency resolution works as expected. We can extend this to symlink other necessary root-level files or directories if needed. - -This strategy provides the best of both worlds: clean, isolated test runs outside the project tree, without breaking the build tooling. +1. **Default to OS Temp:** The new `createTemporaryFixture` function will, by + default, create temporary directories in the OS's standard temp location + (e.g., `Deno.makeTempDir()`). +2. **Intelligent Copying/Symlinking:** To solve the dependency issue, the + function will: a. Copy the specific fixture files (e.g., from + `packages/plugin-vite/tests/fixtures/my-app`) into the temporary directory. + b. Create a **symlink** from `deno.json` in the temporary directory back to + the repository's root `deno.json` + (`ln -s /path/to/repo/deno.json /tmp/test-123/deno.json`). c. This approach + keeps the test environment isolated while ensuring that Deno's dependency + resolution works as expected. We can extend this to symlink other necessary + root-level files or directories if needed. + +This strategy provides the best of both worlds: clean, isolated test runs +outside the project tree, without breaking the build tooling. ## 4. Refactoring Plan -1. **Phase 1: Create the `internal` Package** - - Set up the directory structure for `packages/internal`. - - Move all testing dependencies into its `deno.json`. - - Create the initial `TestServer` class and fixture management utilities, implementing the robust health check and temporary directory strategies. - -2. **Phase 2: Refactor `packages/plugin-vite` Tests** - - Update `packages/plugin-vite/deno.json` to depend on the local `internal` package. - - Rewrite `packages/plugin-vite/tests/test_utils.ts` to be a thin wrapper, if needed at all. Most logic should be imported directly from `internal`. - - Refactor the actual tests (e.g., `dev_server_test.ts`) to use the new `TestServer` class (`await using server = await TestServer.create(...)`). - - This will eliminate the cross-package import to `fresh` and the in-repository temporary folders. - -3. **Phase 3: Refactor `packages/fresh` Tests** - - Update `packages/fresh/deno.json` to depend on the `internal` package. - - Delete `packages/fresh/tests/test_utils.tsx`. - - Move any reusable, non-JSX utility functions (like `parseHtml`, `assertSelector`) to `packages/internal/src/asserts.ts`. - - Move the JSX helper components (`Doc`, `favicon`) into a dedicated file within the `packages/fresh/tests` directory, but **not** a general-purpose utility file. They are specific to the tests that use them. - - Refactor the tests (e.g., `islands_test.tsx`) to use the `TestServer` from the `internal` package. - -4. **Phase 4: Fixture Consolidation** - - Analyze all fixtures across all packages. - - If any fixture is used by tests in more than one package, move it to `packages/internal/fixtures`. - - Fixtures used only by a single package will remain in that package's test directory. +1. **Phase 1: Create the `internal` Package** + - Set up the directory structure for `packages/internal`. + - Move all testing dependencies into its `deno.json`. + - Create the initial `TestServer` class and fixture management utilities, + implementing the robust health check and temporary directory strategies. + +2. **Phase 2: Refactor `packages/plugin-vite` Tests** + - Update `packages/plugin-vite/deno.json` to depend on the local `internal` + package. + - Rewrite `packages/plugin-vite/tests/test_utils.ts` to be a thin wrapper, if + needed at all. Most logic should be imported directly from `internal`. + - Refactor the actual tests (e.g., `dev_server_test.ts`) to use the new + `TestServer` class (`await using server = await TestServer.create(...)`). + - This will eliminate the cross-package import to `fresh` and the + in-repository temporary folders. + +3. **Phase 3: Refactor `packages/fresh` Tests** + - Update `packages/fresh/deno.json` to depend on the `internal` package. + - Delete `packages/fresh/tests/test_utils.tsx`. + - Move any reusable, non-JSX utility functions (like `parseHtml`, + `assertSelector`) to `packages/internal/src/asserts.ts`. + - Move the JSX helper components (`Doc`, `favicon`) into a dedicated file + within the `packages/fresh/tests` directory, but **not** a general-purpose + utility file. They are specific to the tests that use them. + - Refactor the tests (e.g., `islands_test.tsx`) to use the `TestServer` from + the `internal` package. + +4. **Phase 4: Fixture Consolidation** + - Analyze all fixtures across all packages. + - If any fixture is used by tests in more than one package, move it to + `packages/internal/fixtures`. + - Fixtures used only by a single package will remain in that package's test + directory. ## 5. Conclusion -This plan systematically dismantles the current tangled test suite and replaces it with a modern, centralized, and robust architecture. By introducing the `internal` package and the `TestServer` abstraction, we can eliminate redundant code, fix brittle implementation details, and make the entire testing process easier to understand and maintain. The proposed solution for the temporary directory problem provides a path to clean up the repository without breaking the underlying build and dependency resolution mechanisms. +This plan systematically dismantles the current tangled test suite and replaces +it with a modern, centralized, and robust architecture. By introducing the +`internal` package and the `TestServer` abstraction, we can eliminate redundant +code, fix brittle implementation details, and make the entire testing process +easier to understand and maintain. The proposed solution for the temporary +directory problem provides a path to clean up the repository without breaking +the underlying build and dependency resolution mechanisms. diff --git a/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index de94d900e6a..51edc336d66 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -4,7 +4,7 @@ import { App } from "fresh"; import { asset } from "fresh/runtime"; import { FakeServer } from "./test_utils.ts"; import { BUILD_ID } from "@fresh/build-id"; -import { parseHtml } from "../tests/test_utils.tsx"; +import { parseHtml } from "@fresh/test-utils"; 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..29ad48d0978 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -5,10 +5,7 @@ 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"; +import { getStdOutput, withChildProcessServer } from "@fresh/test-utils"; import { staticFiles } from "../middlewares/static_files.ts"; Deno.test({ diff --git a/packages/fresh/src/dev/update_check_test.ts b/packages/fresh/src/dev/update_check_test.ts index e522f60ac8c..c15f354cafd 100644 --- a/packages/fresh/src/dev/update_check_test.ts +++ b/packages/fresh/src/dev/update_check_test.ts @@ -1,6 +1,6 @@ import * as path from "@std/path"; import denoJson from "../../deno.json" with { type: "json" }; -import { getStdOutput } from "../../tests/test_utils.tsx"; +import { getStdOutput } from "@fresh/test-utils"; import { expect } from "@std/expect"; import { withTmpDir } from "../test_utils.ts"; import type { CheckFile } from "./update_check.ts"; diff --git a/packages/fresh/src/fs_routes_test.tsx b/packages/fresh/src/fs_routes_test.tsx index 1332d0da760..e50fd7e6d17 100644 --- a/packages/fresh/src/fs_routes_test.tsx +++ b/packages/fresh/src/fs_routes_test.tsx @@ -6,7 +6,7 @@ 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 { parseHtml } from "@fresh/test-utils"; import type { Context } from "./context.ts"; import { HttpError } from "./error.ts"; import { crawlRouteDir } from "./dev/fs_crawl.ts"; diff --git a/packages/fresh/tests/active_links_test.tsx b/packages/fresh/tests/active_links_test.tsx index 2d5262e8a07..1c73c8d5783 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, parseHtml, withBrowserApp, -} from "./test_utils.tsx"; - -import { FakeServer } from "../src/test_utils.ts"; +} from "@fresh/test-utils"; +import { ALL_ISLAND_DIR, buildProd, Doc } from "./test_utils.tsx"; +import { FakeServer } from "@fresh/test-utils"; 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..342ed25d8ff 100644 --- a/packages/fresh/tests/doc_examples_test.tsx +++ b/packages/fresh/tests/doc_examples_test.tsx @@ -5,7 +5,7 @@ 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 { withTmpDir } from "@fresh/test-utils"; import { FRESH_VERSION, PREACT_VERSION } from "../../update/src/update.ts"; Deno.test("Docs Code example checks", async () => { diff --git a/packages/fresh/tests/head_test.tsx b/packages/fresh/tests/head_test.tsx index b43356e893d..f0439857662 100644 --- a/packages/fresh/tests/head_test.tsx +++ b/packages/fresh/tests/head_test.tsx @@ -1,13 +1,9 @@ import { App, staticFiles } from "fresh"; import { Head } from "fresh/runtime"; -import { - buildProd, - parseHtml, - waitFor, - withBrowserApp, -} from "./test_utils.tsx"; +import { parseHtml, waitFor, withBrowserApp } from "@fresh/test-utils"; +import { buildProd } from "./test_utils.tsx"; import { expect } from "@std/expect"; -import { FakeServer } from "../src/test_utils.ts"; +import { FakeServer } from "@fresh/test-utils"; import * as path from "@std/path"; Deno.test("Head - ssr - updates title", async () => { @@ -165,7 +161,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 +191,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 +236,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 +281,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..f1628bc5516 100644 --- a/packages/fresh/tests/islands_test.tsx +++ b/packages/fresh/tests/islands_test.tsx @@ -11,21 +11,20 @@ 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 { parseHtml, waitForText, withBrowserApp } from "@fresh/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 { FakeServer } from "@fresh/test-utils"; import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts"; import { ComputedSignal } from "./fixtures_islands/Computed.tsx"; import { EnvIsland } from "./fixtures_islands/EnvIsland.tsx"; @@ -68,7 +67,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 +89,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 +115,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 +140,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 +161,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 +185,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 +204,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 +228,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 +254,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 +280,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 +313,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 +345,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 +383,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 +418,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 +474,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 +515,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 +553,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 +571,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 +588,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] 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}`, + ); + } +} + +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/test-utils/src/fs.ts b/packages/test-utils/src/fs.ts new file mode 100644 index 00000000000..c4c25bce702 --- /dev/null +++ b/packages/test-utils/src/fs.ts @@ -0,0 +1,38 @@ +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)); + +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/test-utils/src/mod.ts b/packages/test-utils/src/mod.ts new file mode 100644 index 00000000000..e7403837c78 --- /dev/null +++ b/packages/test-utils/src/mod.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/test-utils/src/process.ts b/packages/test-utils/src/process.ts new file mode 100644 index 00000000000..e7d53b51b37 --- /dev/null +++ b/packages/test-utils/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/test-utils/src/server.ts b/packages/test-utils/src/server.ts new file mode 100644 index 00000000000..bcdedb98784 --- /dev/null +++ b/packages/test-utils/src/server.ts @@ -0,0 +1,169 @@ +// 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; + +export class FakeServer { + constructor( + public handler: ( + req: Request, + info: Deno.ServeHandlerInfo, + ) => Response | Promise, + ) {} + + async get(path: string, init?: RequestInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, init); + return await this.handler(req, STUB); + } + async post(path: string, body?: BodyInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "post", body }); + return await this.handler(req, STUB); + } + async patch(path: string, body?: BodyInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "patch", body }); + return await this.handler(req, STUB); + } + async put(path: string, body?: BodyInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "put", body }); + return await this.handler(req, STUB); + } + async delete(path: string): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "delete" }); + return await this.handler(req, STUB); + } + async head(path: string): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "head" }); + return await this.handler(req, STUB); + } + async options(path: string): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "options" }); + return await this.handler(req, STUB); + } + + async request(req: Request): Promise { + return await this.handler(req, STUB); + } + + private toUrl(path: string) { + return new URL(path, "http://localhost/"); + } +} + +const DEFAULT_CONFIG: ResolvedFreshConfig = { + root: "", + mode: "production", + basePath: "", +}; + +export function serveMiddleware( + middleware: (ctx: Context) => Response | Promise, + options: { + config?: ResolvedFreshConfig; + buildCache?: BuildCache; + next?: () => Promise; + route?: string | null; + } = {}, +): FakeServer { + return new FakeServer(async (req) => { + const next = options.next ?? + (() => new Response("not found", { status: 404 })); + const config = options.config ?? DEFAULT_CONFIG; + const buildCache = options.buildCache ?? + new MockBuildCache([], options.config?.mode ?? "production"); + + const ctx = new Context( + req, + new URL(req.url), + DEFAULT_CONN_INFO, + options.route ?? null, + {}, + config, + () => Promise.resolve(next()), + buildCache, + ); + return await middleware(ctx); + }); +} + +export class MockBuildCache implements BuildCache { + #files: FsRouteFile[]; + root = ""; + clientEntry = ""; + islandRegistry: ServerIslandRegistry = new Map(); + features = { errorOverlay: false }; + + constructor(files: FsRouteFile[], mode: "development" | "production") { + this.features.errorOverlay = mode === "development"; + this.#files = files; + } + + getEntryAssets(): string[] { + return []; + } + + getFsRoutes(): Command[] { + return fsItemsToCommands(this.#files); + } + + readFile(_pathname: string): Promise { + return Promise.resolve(null); + } +} + +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/test-utils/src/util.ts b/packages/test-utils/src/util.ts new file mode 100644 index 00000000000..b7dc7af621f --- /dev/null +++ b/packages/test-utils/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/update/src/update_test.ts b/packages/update/src/update_test.ts index e2abdac761e..b919bdda89a 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/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..b80f4c8462a 100644 --- a/www/main_test.ts +++ b/www/main_test.ts @@ -1,7 +1,4 @@ -import { - withBrowser, - withChildProcessServer, -} from "../packages/fresh/tests/test_utils.tsx"; +import { withBrowser, withChildProcessServer } from "@fresh/test-utils"; import { expect } from "@std/expect"; import { retry } from "@std/async/retry"; import { From 5909cb74edda81db6ed11aa44a62cad8fe6cdd8c Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 20:30:37 +0200 Subject: [PATCH 03/14] test: refactor snapshot #2 --- packages/fresh/tests/partials_test.tsx | 7 +++--- packages/test-utils/src/dom.ts | 34 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index 78bf93fa37f..a2e7231d35c 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -1,15 +1,16 @@ -import { App, staticFiles } from "fresh"; -import { Partial } from "fresh/runtime"; import { + assertMetaContent, assertNotSelector, parseHtml, waitFor, waitForText, withBrowserApp, } from "@fresh/test-utils"; +import { App, staticFiles } from "fresh"; +import { Partial } from "fresh/runtime"; +// (all generic test utils are imported above) import { ALL_ISLAND_DIR, - assertMetaContent, buildProd, charset, Doc, diff --git a/packages/test-utils/src/dom.ts b/packages/test-utils/src/dom.ts index a9ff611d348..a294c8aac58 100644 --- a/packages/test-utils/src/dom.ts +++ b/packages/test-utils/src/dom.ts @@ -96,6 +96,40 @@ export function assertNotSelector(doc: Document, selector: string) { } } +/** + * 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, From 54f70c27266d933958ff37f5f24e411438eaa5d4 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 20:41:23 +0200 Subject: [PATCH 04/14] test: refactor snapshot #3 --- packages/plugin-vite/tests/test_utils.ts | 18 +---- packages/test-utils/README.md | 94 ++++++++++++++++++++++-- packages/test-utils/src/fs.ts | 18 +++++ 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/packages/plugin-vite/tests/test_utils.ts b/packages/plugin-vite/tests/test_utils.ts index 9143ffe97f7..e8c0cb92724 100644 --- a/packages/plugin-vite/tests/test_utils.ts +++ b/packages/plugin-vite/tests/test_utils.ts @@ -2,6 +2,7 @@ import { createBuilder } from "vite"; import * as path from "@std/path"; import { walk } from "@std/fs/walk"; import { + updateFile, usingEnv, withChildProcessServer, withTmpDir, @@ -10,20 +11,7 @@ import { 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/test-utils async function copyDir(from: string, to: string) { const entries = walk(from, { @@ -173,7 +161,7 @@ export async function buildVite( }; } -export { usingEnv }; +export { updateFile, usingEnv }; export interface ProdOptions { cwd: string; diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md index db1a9e56af2..f41ddeed815 100644 --- a/packages/test-utils/README.md +++ b/packages/test-utils/README.md @@ -2,16 +2,98 @@ Internal shared testing utilities for the Fresh monorepo. -- Not published externally. -- Provides common helpers for browser/E2E tests, child process servers, - fixtures, DOM assertions, and lightweight server/middleware testing. +- Not published externally +- Import via the workspace alias: `@fresh/test-utils` +- Provides common helpers for browser/E2E, DOM assertions, temp files/dirs, + child process servers, and lightweight server/middleware testing -Usage from tests in this monorepo: +## What’s included + +These modules are re-exported from `src/mod.ts` and can be imported directly +from `@fresh/test-utils`. + +### Browser + +- `withBrowser(fn)` — Launch a headless browser page and run assertions. Dumps + page HTML on failure. +- `withBrowserApp(appOrHandler, fn)` — Serve a Fresh app or `Deno.ServeHandler` + on an ephemeral port and open a page. + - Accepts either an `app` with a `handler()` method or a raw + `Deno.ServeHandler`. + +### DOM + +- `parseHtml(html)` — Parse HTML into a `Document` with `.debug()` + pretty-printer. +- `assertSelector(doc, selector)` — Assert a selector exists and show a pretty + DOM on failure. +- `assertNotSelector(doc, selector)` — Assert a selector does not exist. +- `waitForText(page, selector, text)` — Wait until a selector’s textContent + equals the expected value. +- `assertMetaContent(doc, nameOrProperty, expected)` — Assert a `` tag by + `name` or `property` has `content`. + +### FS + +- `withTmpDir(options?)` — Create a temp dir and auto-clean it up on dispose. +- `writeFiles(dir, files)` — Write multiple files, creating parent directories. +- `updateFile(path, fn)` — Update a file’s text, returning an async disposable + that restores the original. + +### Process + +- `withChildProcessServer(options, fn)` — Spawn a Deno command and wait until an + address appears in stdout/stderr, then call `fn(address)` and clean up. +- `getStdOutput(out)` — Decode stdout/stderr from `Deno.CommandOutput` without + ANSI escapes. + +### Server + +- `FakeServer(handler)` — Call your handler directly via + `get/post/put/patch/delete/head/options` helpers. +- `serveMiddleware(mw, options)` — Wrap a Fresh middleware and call it with a + built `Context`. +- `MockBuildCache(files, mode)` — Minimal `BuildCache` for middleware tests. +- `createFakeFs(files)` — Tiny fake FS for internal build/middleware unit tests. + +### Util + +- `waitFor(cond, timeoutMs?)` — Poll a condition until true or timeout. +- `usingEnv(vars, fn)` — Temporarily set env vars for the duration of `fn`. + +## Examples + +Import helpers from the alias: ```ts import { + assertMetaContent, parseHtml, - withBrowser, - withChildProcessServer, + withBrowserApp, } from "@fresh/test-utils"; + +await withBrowserApp(app.handler(), async (page, address) => { + await page.goto(address); + const doc = parseHtml(await page.content()); + assertMetaContent(doc, "description", "Fresh site"); +}); ``` + +Temporarily modify a file during a test: + +```ts +import { updateFile } from "@fresh/test-utils"; + +await using _ = await updateFile( + "./vite.config.ts", + (txt) => txt.replace("fresh()", "fresh({ devInspector: true })"), +); +// run assertions that depend on the modified config... +``` + +## Notes + +- This package is internal to the monorepo and imports some Fresh internals by + path for testing utilities. That’s intentional. +- Keep package-specific helpers (like full build flows or fixture copying) in + their package tests; add only generic reusable bits here. diff --git a/packages/test-utils/src/fs.ts b/packages/test-utils/src/fs.ts index c4c25bce702..90d6c9ae725 100644 --- a/packages/test-utils/src/fs.ts +++ b/packages/test-utils/src/fs.ts @@ -19,6 +19,24 @@ export async function writeFiles(dir: string, files: Record) { 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> { From e7f123daf7e66e0306821818c1244545928b4134 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 21:17:36 +0200 Subject: [PATCH 05/14] test: refactor snapshot #4 --- packages/fresh/tests/doc_examples_test.tsx | 16 +++++++++------- packages/test-utils/src/mod.ts | 1 + packages/test-utils/src/versions.ts | 6 ++++++ 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 packages/test-utils/src/versions.ts diff --git a/packages/fresh/tests/doc_examples_test.tsx b/packages/fresh/tests/doc_examples_test.tsx index 342ed25d8ff..5868212f648 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/test-utils 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 "@fresh/test-utils"; -import { FRESH_VERSION, PREACT_VERSION } from "../../update/src/update.ts"; +import { + withTmpDir, + FRESH_VERSION, + PREACT_VERSION, + TAILWIND_PLUGIN_VERSION, +} from "@fresh/test-utils"; 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/test-utils/src/mod.ts b/packages/test-utils/src/mod.ts index e7403837c78..97c0f44fd1f 100644 --- a/packages/test-utils/src/mod.ts +++ b/packages/test-utils/src/mod.ts @@ -4,3 +4,4 @@ export * from "./dom.ts"; export * from "./fs.ts"; export * from "./server.ts"; export * from "./util.ts"; +export * from "./versions.ts"; diff --git a/packages/test-utils/src/versions.ts b/packages/test-utils/src/versions.ts new file mode 100644 index 00000000000..93df21d9ec3 --- /dev/null +++ b/packages/test-utils/src/versions.ts @@ -0,0 +1,6 @@ +export { FRESH_VERSION, PREACT_VERSION } from "../../update/src/update.ts"; + +// Read plugin-tailwindcss version from its deno.json +// This is safe as @fresh/test-utils is internal to the monorepo. +import twDenoJson from "../../plugin-tailwindcss/deno.json" with { type: "json" }; +export const TAILWIND_PLUGIN_VERSION: string = String(twDenoJson.version); From 7e4502d24f2ae8d33c30bbc13a4b75d494c22dd3 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:14:14 +0200 Subject: [PATCH 06/14] test: refactor snapshot #5 --- deno.json | 4 +++- packages/fresh/src/context_test.tsx | 2 +- packages/fresh/src/dev/builder_test.ts | 2 +- packages/fresh/src/dev/update_check_test.ts | 2 +- packages/fresh/src/fs_routes_test.tsx | 2 +- packages/fresh/tests/active_links_test.tsx | 4 ++-- packages/fresh/tests/doc_examples_test.tsx | 6 +++--- packages/fresh/tests/head_test.tsx | 4 ++-- packages/fresh/tests/islands_test.tsx | 4 ++-- packages/fresh/tests/partials_test.tsx | 4 ++-- packages/init/src/init_test.ts | 4 ++-- packages/internal/deno.json | 7 +++++++ packages/internal/src/browser.ts | 1 + packages/internal/src/dom.ts | 1 + packages/internal/src/fs.ts | 1 + packages/internal/src/process.ts | 1 + packages/internal/src/server.ts | 1 + packages/internal/src/test-utils.ts | 6 ++++++ packages/internal/src/util.ts | 1 + packages/internal/src/versions.ts | 1 + packages/plugin-vite/tests/build_test.ts | 2 +- packages/plugin-vite/tests/dev_server_test.ts | 2 +- packages/plugin-vite/tests/test_utils.ts | 2 +- packages/update/src/update_test.ts | 2 +- www/main_test.ts | 2 +- 25 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 packages/internal/deno.json create mode 100644 packages/internal/src/browser.ts create mode 100644 packages/internal/src/dom.ts create mode 100644 packages/internal/src/fs.ts create mode 100644 packages/internal/src/process.ts create mode 100644 packages/internal/src/server.ts create mode 100644 packages/internal/src/test-utils.ts create mode 100644 packages/internal/src/util.ts create mode 100644 packages/internal/src/versions.ts diff --git a/deno.json b/deno.json index b2b9523e11b..07351d44737 100644 --- a/deno.json +++ b/deno.json @@ -39,7 +39,9 @@ "exclude": ["**/*_test.*", "src/__OLD/**", "*.todo", "**/tests/**"] }, "imports": { - "@fresh/test-utils": "./packages/test-utils/src/mod.ts", + "@fresh/test-utils": "./packages/test-utils/src/mod.ts", + "@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/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index 51edc336d66..37e6017ca6e 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -4,7 +4,7 @@ import { App } from "fresh"; import { asset } from "fresh/runtime"; import { FakeServer } from "./test_utils.ts"; import { BUILD_ID } from "@fresh/build-id"; -import { parseHtml } from "@fresh/test-utils"; +import { parseHtml } from "@fresh/internal/test-utils"; 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 29ad48d0978..24018870a1b 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -5,7 +5,7 @@ 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 "@fresh/test-utils"; +import { getStdOutput, withChildProcessServer } from "@fresh/internal/test-utils"; import { staticFiles } from "../middlewares/static_files.ts"; Deno.test({ diff --git a/packages/fresh/src/dev/update_check_test.ts b/packages/fresh/src/dev/update_check_test.ts index c15f354cafd..2f8285fb96a 100644 --- a/packages/fresh/src/dev/update_check_test.ts +++ b/packages/fresh/src/dev/update_check_test.ts @@ -1,6 +1,6 @@ import * as path from "@std/path"; import denoJson from "../../deno.json" with { type: "json" }; -import { getStdOutput } from "@fresh/test-utils"; +import { getStdOutput } from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; import { withTmpDir } from "../test_utils.ts"; import type { CheckFile } from "./update_check.ts"; diff --git a/packages/fresh/src/fs_routes_test.tsx b/packages/fresh/src/fs_routes_test.tsx index e50fd7e6d17..6c74ae495e6 100644 --- a/packages/fresh/src/fs_routes_test.tsx +++ b/packages/fresh/src/fs_routes_test.tsx @@ -6,7 +6,7 @@ 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 "@fresh/test-utils"; +import { parseHtml } from "@fresh/internal/test-utils"; import type { Context } from "./context.ts"; import { HttpError } from "./error.ts"; import { crawlRouteDir } from "./dev/fs_crawl.ts"; diff --git a/packages/fresh/tests/active_links_test.tsx b/packages/fresh/tests/active_links_test.tsx index 1c73c8d5783..839331be131 100644 --- a/packages/fresh/tests/active_links_test.tsx +++ b/packages/fresh/tests/active_links_test.tsx @@ -4,9 +4,9 @@ import { assertSelector, parseHtml, withBrowserApp, -} from "@fresh/test-utils"; +} from "@fresh/internal/test-utils"; import { ALL_ISLAND_DIR, buildProd, Doc } from "./test_utils.tsx"; -import { FakeServer } from "@fresh/test-utils"; +import { FakeServer } from "@fresh/internal/test-utils"; import { Partial } from "fresh/runtime"; const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); diff --git a/packages/fresh/tests/doc_examples_test.tsx b/packages/fresh/tests/doc_examples_test.tsx index 5868212f648..18bcde06b29 100644 --- a/packages/fresh/tests/doc_examples_test.tsx +++ b/packages/fresh/tests/doc_examples_test.tsx @@ -1,14 +1,14 @@ -// Versions and plugin-tailwind version are provided via @fresh/test-utils +// 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 "@fresh/internal/test-utils"; import { - withTmpDir, FRESH_VERSION, PREACT_VERSION, TAILWIND_PLUGIN_VERSION, -} from "@fresh/test-utils"; +} from "@fresh/internal/versions"; Deno.test("Docs Code example checks", async () => { await using tmp = await withTmpDir(); diff --git a/packages/fresh/tests/head_test.tsx b/packages/fresh/tests/head_test.tsx index f0439857662..a37341ac93d 100644 --- a/packages/fresh/tests/head_test.tsx +++ b/packages/fresh/tests/head_test.tsx @@ -1,9 +1,9 @@ import { App, staticFiles } from "fresh"; import { Head } from "fresh/runtime"; -import { parseHtml, waitFor, withBrowserApp } from "@fresh/test-utils"; +import { parseHtml, waitFor, withBrowserApp } from "@fresh/internal/test-utils"; import { buildProd } from "./test_utils.tsx"; import { expect } from "@std/expect"; -import { FakeServer } from "@fresh/test-utils"; +import { FakeServer } from "@fresh/internal/test-utils"; import * as path from "@std/path"; Deno.test("Head - ssr - updates title", async () => { diff --git a/packages/fresh/tests/islands_test.tsx b/packages/fresh/tests/islands_test.tsx index f1628bc5516..2ea0ca8c114 100644 --- a/packages/fresh/tests/islands_test.tsx +++ b/packages/fresh/tests/islands_test.tsx @@ -11,7 +11,7 @@ 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 { parseHtml, waitForText, withBrowserApp } from "@fresh/test-utils"; +import { parseHtml, waitForText, withBrowserApp } from "@fresh/internal/test-utils"; import { ALL_ISLAND_DIR, buildProd, @@ -24,7 +24,7 @@ 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 "@fresh/test-utils"; +import { FakeServer } from "@fresh/internal/test-utils"; import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts"; import { ComputedSignal } from "./fixtures_islands/Computed.tsx"; import { EnvIsland } from "./fixtures_islands/EnvIsland.tsx"; diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index a2e7231d35c..9f5f058f3be 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -5,7 +5,7 @@ import { waitFor, waitForText, withBrowserApp, -} from "@fresh/test-utils"; +} from "@fresh/internal/test-utils"; import { App, staticFiles } from "fresh"; import { Partial } from "fresh/runtime"; // (all generic test utils are imported above) @@ -19,7 +19,7 @@ import { import { SelfCounter } from "./fixtures_islands/SelfCounter.tsx"; import { expect } from "@std/expect"; import { PartialInIsland } from "./fixtures_islands/PartialInIsland.tsx"; -import { FakeServer } from "@fresh/test-utils"; +import { FakeServer } from "@fresh/internal/test-utils"; import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx"; import { OptOutPartialLink } from "./fixtures_islands/OptOutPartialLink.tsx"; import * as path from "@std/path"; diff --git a/packages/init/src/init_test.ts b/packages/init/src/init_test.ts index a9661626c8a..485a63d9863 100644 --- a/packages/init/src/init_test.ts +++ b/packages/init/src/init_test.ts @@ -12,8 +12,8 @@ import { waitForText, withBrowser, withChildProcessServer, -} from "@fresh/test-utils"; -import { withTmpDir as withTmpDirBase } from "@fresh/test-utils"; +} from "@fresh/internal/test-utils"; +import { 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/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..9136918b33a --- /dev/null +++ b/packages/internal/src/browser.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/browser.ts"; diff --git a/packages/internal/src/dom.ts b/packages/internal/src/dom.ts new file mode 100644 index 00000000000..8bfd217c11b --- /dev/null +++ b/packages/internal/src/dom.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/dom.ts"; diff --git a/packages/internal/src/fs.ts b/packages/internal/src/fs.ts new file mode 100644 index 00000000000..e5e0ba52394 --- /dev/null +++ b/packages/internal/src/fs.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/fs.ts"; diff --git a/packages/internal/src/process.ts b/packages/internal/src/process.ts new file mode 100644 index 00000000000..284651048cb --- /dev/null +++ b/packages/internal/src/process.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/process.ts"; diff --git a/packages/internal/src/server.ts b/packages/internal/src/server.ts new file mode 100644 index 00000000000..852f6b6b1e7 --- /dev/null +++ b/packages/internal/src/server.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/server.ts"; 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..d257af45443 --- /dev/null +++ b/packages/internal/src/util.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/util.ts"; diff --git a/packages/internal/src/versions.ts b/packages/internal/src/versions.ts new file mode 100644 index 00000000000..c6998f275ff --- /dev/null +++ b/packages/internal/src/versions.ts @@ -0,0 +1 @@ +export * from "../../test-utils/src/versions.ts"; diff --git a/packages/plugin-vite/tests/build_test.ts b/packages/plugin-vite/tests/build_test.ts index 82a1568a0fe..f04b5ed5da1 100644 --- a/packages/plugin-vite/tests/build_test.ts +++ b/packages/plugin-vite/tests/build_test.ts @@ -1,5 +1,5 @@ import { expect } from "@std/expect"; -import { waitFor, waitForText, withBrowser } from "@fresh/test-utils"; +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 ebccb8b2095..bf0678ec582 100644 --- a/packages/plugin-vite/tests/dev_server_test.ts +++ b/packages/plugin-vite/tests/dev_server_test.ts @@ -1,6 +1,6 @@ import * as path from "@std/path"; import { expect } from "@std/expect"; -import { waitFor, waitForText, withBrowser } from "@fresh/test-utils"; +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 e8c0cb92724..1cd373cf9ed 100644 --- a/packages/plugin-vite/tests/test_utils.ts +++ b/packages/plugin-vite/tests/test_utils.ts @@ -6,7 +6,7 @@ import { usingEnv, withChildProcessServer, withTmpDir, -} from "@fresh/test-utils"; +} 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"); diff --git a/packages/update/src/update_test.ts b/packages/update/src/update_test.ts index b919bdda89a..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/test-utils"; +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 b80f4c8462a..d6b7cae08bc 100644 --- a/www/main_test.ts +++ b/www/main_test.ts @@ -1,4 +1,4 @@ -import { withBrowser, withChildProcessServer } from "@fresh/test-utils"; +import { withBrowser, withChildProcessServer } from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; import { retry } from "@std/async/retry"; import { From 66fcf8d4314185be3bfea0c1a3e111f29b8a778a Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:27:37 +0200 Subject: [PATCH 07/14] test: refactor snapshot #6 --- deno.json | 5 +- packages/fresh/src/dev/builder_test.ts | 5 +- packages/fresh/tests/islands_test.tsx | 6 +- packages/internal/README.md | 8 ++ packages/internal/src/browser.ts | 56 +++++++- packages/internal/src/dom.ts | 160 ++++++++++++++++++++- packages/internal/src/fs.ts | 57 +++++++- packages/internal/src/process.ts | 93 ++++++++++++- packages/internal/src/server.ts | 170 ++++++++++++++++++++++- packages/internal/src/util.ts | 36 ++++- packages/internal/src/versions.ts | 9 +- packages/plugin-vite/tests/test_utils.ts | 2 +- packages/test-utils/README.md | 101 +------------- packages/test-utils/deno.json | 3 +- packages/test-utils/src/browser.ts | 56 +------- packages/test-utils/src/dom.ts | 160 +-------------------- packages/test-utils/src/fs.ts | 57 +------- packages/test-utils/src/mod.ts | 8 +- packages/test-utils/src/process.ts | 93 +------------ packages/test-utils/src/server.ts | 170 +---------------------- packages/test-utils/src/util.ts | 36 +---- packages/test-utils/src/versions.ts | 7 +- www/main_test.ts | 5 +- 23 files changed, 612 insertions(+), 691 deletions(-) create mode 100644 packages/internal/README.md diff --git a/deno.json b/deno.json index 07351d44737..565fbde562f 100644 --- a/deno.json +++ b/deno.json @@ -39,9 +39,8 @@ "exclude": ["**/*_test.*", "src/__OLD/**", "*.todo", "**/tests/**"] }, "imports": { - "@fresh/test-utils": "./packages/test-utils/src/mod.ts", - "@fresh/internal/test-utils": "./packages/internal/src/test-utils.ts", - "@fresh/internal/versions": "./packages/internal/src/versions.ts", + "@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/packages/fresh/src/dev/builder_test.ts b/packages/fresh/src/dev/builder_test.ts index 24018870a1b..9504c3d1c5e 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -5,7 +5,10 @@ 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 "@fresh/internal/test-utils"; +import { + getStdOutput, + withChildProcessServer, +} from "@fresh/internal/test-utils"; import { staticFiles } from "../middlewares/static_files.ts"; Deno.test({ diff --git a/packages/fresh/tests/islands_test.tsx b/packages/fresh/tests/islands_test.tsx index 2ea0ca8c114..39ecc284e26 100644 --- a/packages/fresh/tests/islands_test.tsx +++ b/packages/fresh/tests/islands_test.tsx @@ -11,7 +11,11 @@ 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 { parseHtml, waitForText, withBrowserApp } from "@fresh/internal/test-utils"; +import { + parseHtml, + waitForText, + withBrowserApp, +} from "@fresh/internal/test-utils"; import { ALL_ISLAND_DIR, buildProd, 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/src/browser.ts b/packages/internal/src/browser.ts index 9136918b33a..b93547f17a7 100644 --- a/packages/internal/src/browser.ts +++ b/packages/internal/src/browser.ts @@ -1 +1,55 @@ -export * from "../../test-utils/src/browser.ts"; +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 index 8bfd217c11b..a294c8aac58 100644 --- a/packages/internal/src/dom.ts +++ b/packages/internal/src/dom.ts @@ -1 +1,159 @@ -export * from "../../test-utils/src/dom.ts"; +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 index e5e0ba52394..90d6c9ae725 100644 --- a/packages/internal/src/fs.ts +++ b/packages/internal/src/fs.ts @@ -1 +1,56 @@ -export * from "../../test-utils/src/fs.ts"; +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 index 284651048cb..e7d53b51b37 100644 --- a/packages/internal/src/process.ts +++ b/packages/internal/src/process.ts @@ -1 +1,92 @@ -export * from "../../test-utils/src/process.ts"; +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/internal/src/server.ts b/packages/internal/src/server.ts index 852f6b6b1e7..bcdedb98784 100644 --- a/packages/internal/src/server.ts +++ b/packages/internal/src/server.ts @@ -1 +1,169 @@ -export * from "../../test-utils/src/server.ts"; +// 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; + +export class FakeServer { + constructor( + public handler: ( + req: Request, + info: Deno.ServeHandlerInfo, + ) => Response | Promise, + ) {} + + async get(path: string, init?: RequestInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, init); + return await this.handler(req, STUB); + } + async post(path: string, body?: BodyInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "post", body }); + return await this.handler(req, STUB); + } + async patch(path: string, body?: BodyInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "patch", body }); + return await this.handler(req, STUB); + } + async put(path: string, body?: BodyInit): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "put", body }); + return await this.handler(req, STUB); + } + async delete(path: string): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "delete" }); + return await this.handler(req, STUB); + } + async head(path: string): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "head" }); + return await this.handler(req, STUB); + } + async options(path: string): Promise { + const url = this.toUrl(path); + const req = new Request(url, { method: "options" }); + return await this.handler(req, STUB); + } + + async request(req: Request): Promise { + return await this.handler(req, STUB); + } + + private toUrl(path: string) { + return new URL(path, "http://localhost/"); + } +} + +const DEFAULT_CONFIG: ResolvedFreshConfig = { + root: "", + mode: "production", + basePath: "", +}; + +export function serveMiddleware( + middleware: (ctx: Context) => Response | Promise, + options: { + config?: ResolvedFreshConfig; + buildCache?: BuildCache; + next?: () => Promise; + route?: string | null; + } = {}, +): FakeServer { + return new FakeServer(async (req) => { + const next = options.next ?? + (() => new Response("not found", { status: 404 })); + const config = options.config ?? DEFAULT_CONFIG; + const buildCache = options.buildCache ?? + new MockBuildCache([], options.config?.mode ?? "production"); + + const ctx = new Context( + req, + new URL(req.url), + DEFAULT_CONN_INFO, + options.route ?? null, + {}, + config, + () => Promise.resolve(next()), + buildCache, + ); + return await middleware(ctx); + }); +} + +export class MockBuildCache implements BuildCache { + #files: FsRouteFile[]; + root = ""; + clientEntry = ""; + islandRegistry: ServerIslandRegistry = new Map(); + features = { errorOverlay: false }; + + constructor(files: FsRouteFile[], mode: "development" | "production") { + this.features.errorOverlay = mode === "development"; + this.#files = files; + } + + getEntryAssets(): string[] { + return []; + } + + getFsRoutes(): Command[] { + return fsItemsToCommands(this.#files); + } + + readFile(_pathname: string): Promise { + return Promise.resolve(null); + } +} + +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/util.ts b/packages/internal/src/util.ts index d257af45443..b7dc7af621f 100644 --- a/packages/internal/src/util.ts +++ b/packages/internal/src/util.ts @@ -1 +1,35 @@ -export * from "../../test-utils/src/util.ts"; +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 index c6998f275ff..4eb48c0c921 100644 --- a/packages/internal/src/versions.ts +++ b/packages/internal/src/versions.ts @@ -1 +1,8 @@ -export * from "../../test-utils/src/versions.ts"; +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/test_utils.ts b/packages/plugin-vite/tests/test_utils.ts index 1cd373cf9ed..7432a8226a5 100644 --- a/packages/plugin-vite/tests/test_utils.ts +++ b/packages/plugin-vite/tests/test_utils.ts @@ -11,7 +11,7 @@ import { export const DEMO_DIR = path.join(import.meta.dirname!, "..", "demo"); export const FIXTURE_DIR = path.join(import.meta.dirname!, "fixtures"); -// updateFile is now provided by @fresh/test-utils +// updateFile is now provided by @fresh/internal/test-utils async function copyDir(from: string, to: string) { const entries = walk(from, { diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md index f41ddeed815..bfdad634682 100644 --- a/packages/test-utils/README.md +++ b/packages/test-utils/README.md @@ -1,99 +1,6 @@ -# @fresh/test-utils +# DEPRECATED: @fresh/test-utils -Internal shared testing utilities for the Fresh monorepo. +This package has been replaced by `@fresh/internal/test-utils` and +`@fresh/internal/versions`. -- Not published externally -- Import via the workspace alias: `@fresh/test-utils` -- Provides common helpers for browser/E2E, DOM assertions, temp files/dirs, - child process servers, and lightweight server/middleware testing - -## What’s included - -These modules are re-exported from `src/mod.ts` and can be imported directly -from `@fresh/test-utils`. - -### Browser - -- `withBrowser(fn)` — Launch a headless browser page and run assertions. Dumps - page HTML on failure. -- `withBrowserApp(appOrHandler, fn)` — Serve a Fresh app or `Deno.ServeHandler` - on an ephemeral port and open a page. - - Accepts either an `app` with a `handler()` method or a raw - `Deno.ServeHandler`. - -### DOM - -- `parseHtml(html)` — Parse HTML into a `Document` with `.debug()` - pretty-printer. -- `assertSelector(doc, selector)` — Assert a selector exists and show a pretty - DOM on failure. -- `assertNotSelector(doc, selector)` — Assert a selector does not exist. -- `waitForText(page, selector, text)` — Wait until a selector’s textContent - equals the expected value. -- `assertMetaContent(doc, nameOrProperty, expected)` — Assert a `` tag by - `name` or `property` has `content`. - -### FS - -- `withTmpDir(options?)` — Create a temp dir and auto-clean it up on dispose. -- `writeFiles(dir, files)` — Write multiple files, creating parent directories. -- `updateFile(path, fn)` — Update a file’s text, returning an async disposable - that restores the original. - -### Process - -- `withChildProcessServer(options, fn)` — Spawn a Deno command and wait until an - address appears in stdout/stderr, then call `fn(address)` and clean up. -- `getStdOutput(out)` — Decode stdout/stderr from `Deno.CommandOutput` without - ANSI escapes. - -### Server - -- `FakeServer(handler)` — Call your handler directly via - `get/post/put/patch/delete/head/options` helpers. -- `serveMiddleware(mw, options)` — Wrap a Fresh middleware and call it with a - built `Context`. -- `MockBuildCache(files, mode)` — Minimal `BuildCache` for middleware tests. -- `createFakeFs(files)` — Tiny fake FS for internal build/middleware unit tests. - -### Util - -- `waitFor(cond, timeoutMs?)` — Poll a condition until true or timeout. -- `usingEnv(vars, fn)` — Temporarily set env vars for the duration of `fn`. - -## Examples - -Import helpers from the alias: - -```ts -import { - assertMetaContent, - parseHtml, - withBrowserApp, -} from "@fresh/test-utils"; - -await withBrowserApp(app.handler(), async (page, address) => { - await page.goto(address); - const doc = parseHtml(await page.content()); - assertMetaContent(doc, "description", "Fresh site"); -}); -``` - -Temporarily modify a file during a test: - -```ts -import { updateFile } from "@fresh/test-utils"; - -await using _ = await updateFile( - "./vite.config.ts", - (txt) => txt.replace("fresh()", "fresh({ devInspector: true })"), -); -// run assertions that depend on the modified config... -``` - -## Notes - -- This package is internal to the monorepo and imports some Fresh internals by - path for testing utilities. That’s intentional. -- Keep package-specific helpers (like full build flows or fixture copying) in - their package tests; add only generic reusable bits here. +All consumers have been migrated. This directory will be removed. diff --git a/packages/test-utils/deno.json b/packages/test-utils/deno.json index 21634a43216..6c389441876 100644 --- a/packages/test-utils/deno.json +++ b/packages/test-utils/deno.json @@ -1,5 +1,6 @@ { - "name": "@fresh/test-utils", + "name": "@fresh/test-utils-DEPRECATED", + "private": true, "version": "0.1.0", "license": "MIT", "exports": { diff --git a/packages/test-utils/src/browser.ts b/packages/test-utils/src/browser.ts index b93547f17a7..96f0bfce730 100644 --- a/packages/test-utils/src/browser.ts +++ b/packages/test-utils/src/browser.ts @@ -1,55 +1 @@ -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(); - } -} +// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/dom.ts b/packages/test-utils/src/dom.ts index a294c8aac58..96f0bfce730 100644 --- a/packages/test-utils/src/dom.ts +++ b/packages/test-utils/src/dom.ts @@ -1,159 +1 @@ -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; - } -} +// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/fs.ts b/packages/test-utils/src/fs.ts index 90d6c9ae725..96f0bfce730 100644 --- a/packages/test-utils/src/fs.ts +++ b/packages/test-utils/src/fs.ts @@ -1,56 +1 @@ -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}"`); - } - }, - }; -} +// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/mod.ts b/packages/test-utils/src/mod.ts index 97c0f44fd1f..421ab548845 100644 --- a/packages/test-utils/src/mod.ts +++ b/packages/test-utils/src/mod.ts @@ -1,7 +1 @@ -export * from "./process.ts"; -export * from "./browser.ts"; -export * from "./dom.ts"; -export * from "./fs.ts"; -export * from "./server.ts"; -export * from "./util.ts"; -export * from "./versions.ts"; +// Deprecated. Use @fresh/internal/test-utils instead. diff --git a/packages/test-utils/src/process.ts b/packages/test-utils/src/process.ts index e7d53b51b37..96f0bfce730 100644 --- a/packages/test-utils/src/process.ts +++ b/packages/test-utils/src/process.ts @@ -1,92 +1 @@ -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 }; -} +// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/server.ts b/packages/test-utils/src/server.ts index bcdedb98784..96f0bfce730 100644 --- a/packages/test-utils/src/server.ts +++ b/packages/test-utils/src/server.ts @@ -1,169 +1 @@ -// 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; - -export class FakeServer { - constructor( - public handler: ( - req: Request, - info: Deno.ServeHandlerInfo, - ) => Response | Promise, - ) {} - - async get(path: string, init?: RequestInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, init); - return await this.handler(req, STUB); - } - async post(path: string, body?: BodyInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "post", body }); - return await this.handler(req, STUB); - } - async patch(path: string, body?: BodyInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "patch", body }); - return await this.handler(req, STUB); - } - async put(path: string, body?: BodyInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "put", body }); - return await this.handler(req, STUB); - } - async delete(path: string): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "delete" }); - return await this.handler(req, STUB); - } - async head(path: string): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "head" }); - return await this.handler(req, STUB); - } - async options(path: string): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "options" }); - return await this.handler(req, STUB); - } - - async request(req: Request): Promise { - return await this.handler(req, STUB); - } - - private toUrl(path: string) { - return new URL(path, "http://localhost/"); - } -} - -const DEFAULT_CONFIG: ResolvedFreshConfig = { - root: "", - mode: "production", - basePath: "", -}; - -export function serveMiddleware( - middleware: (ctx: Context) => Response | Promise, - options: { - config?: ResolvedFreshConfig; - buildCache?: BuildCache; - next?: () => Promise; - route?: string | null; - } = {}, -): FakeServer { - return new FakeServer(async (req) => { - const next = options.next ?? - (() => new Response("not found", { status: 404 })); - const config = options.config ?? DEFAULT_CONFIG; - const buildCache = options.buildCache ?? - new MockBuildCache([], options.config?.mode ?? "production"); - - const ctx = new Context( - req, - new URL(req.url), - DEFAULT_CONN_INFO, - options.route ?? null, - {}, - config, - () => Promise.resolve(next()), - buildCache, - ); - return await middleware(ctx); - }); -} - -export class MockBuildCache implements BuildCache { - #files: FsRouteFile[]; - root = ""; - clientEntry = ""; - islandRegistry: ServerIslandRegistry = new Map(); - features = { errorOverlay: false }; - - constructor(files: FsRouteFile[], mode: "development" | "production") { - this.features.errorOverlay = mode === "development"; - this.#files = files; - } - - getEntryAssets(): string[] { - return []; - } - - getFsRoutes(): Command[] { - return fsItemsToCommands(this.#files); - } - - readFile(_pathname: string): Promise { - return Promise.resolve(null); - } -} - -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)]); - }, - }; -} +// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/util.ts b/packages/test-utils/src/util.ts index b7dc7af621f..96f0bfce730 100644 --- a/packages/test-utils/src/util.ts +++ b/packages/test-utils/src/util.ts @@ -1,35 +1 @@ -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); - } - }, - }; -} +// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/versions.ts b/packages/test-utils/src/versions.ts index 93df21d9ec3..fac5ea76420 100644 --- a/packages/test-utils/src/versions.ts +++ b/packages/test-utils/src/versions.ts @@ -1,6 +1 @@ -export { FRESH_VERSION, PREACT_VERSION } from "../../update/src/update.ts"; - -// Read plugin-tailwindcss version from its deno.json -// This is safe as @fresh/test-utils is internal to the monorepo. -import twDenoJson from "../../plugin-tailwindcss/deno.json" with { type: "json" }; -export const TAILWIND_PLUGIN_VERSION: string = String(twDenoJson.version); +// DEPRECATED: moved to @fresh/internal/versions diff --git a/www/main_test.ts b/www/main_test.ts index d6b7cae08bc..504e44d44ae 100644 --- a/www/main_test.ts +++ b/www/main_test.ts @@ -1,4 +1,7 @@ -import { withBrowser, withChildProcessServer } from "@fresh/internal/test-utils"; +import { + withBrowser, + withChildProcessServer, +} from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; import { retry } from "@std/async/retry"; import { From adf00be68f51ed95402168118b4848993db61864 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:34:19 +0200 Subject: [PATCH 08/14] test: refactor snapshot #7 --- deno.lock | 14 -------------- packages/test-utils/README.md | 6 ------ packages/test-utils/deno.json | 26 -------------------------- packages/test-utils/src/browser.ts | 1 - packages/test-utils/src/dom.ts | 1 - packages/test-utils/src/fs.ts | 1 - packages/test-utils/src/mod.ts | 1 - packages/test-utils/src/process.ts | 1 - packages/test-utils/src/server.ts | 1 - packages/test-utils/src/util.ts | 1 - packages/test-utils/src/versions.ts | 1 - 11 files changed, 54 deletions(-) delete mode 100644 packages/test-utils/README.md delete mode 100644 packages/test-utils/deno.json delete mode 100644 packages/test-utils/src/browser.ts delete mode 100644 packages/test-utils/src/dom.ts delete mode 100644 packages/test-utils/src/fs.ts delete mode 100644 packages/test-utils/src/mod.ts delete mode 100644 packages/test-utils/src/process.ts delete mode 100644 packages/test-utils/src/server.ts delete mode 100644 packages/test-utils/src/util.ts delete mode 100644 packages/test-utils/src/versions.ts diff --git a/deno.lock b/deno.lock index a6d1393485c..ea888810b63 100644 --- a/deno.lock +++ b/deno.lock @@ -3750,20 +3750,6 @@ "npm:vite@^7.1.4" ] }, - "packages/test-utils": { - "dependencies": [ - "jsr:@astral/astral@~0.5.3", - "jsr:@fresh/core@2", - "jsr:@std/assert@^1.0.8", - "jsr:@std/async@^1.0.13", - "jsr:@std/expect@^1.0.16", - "jsr:@std/fmt@^1.0.8", - "jsr:@std/fs@1", - "jsr:@std/path@1", - "jsr:@std/streams@1", - "npm:linkedom@~0.18.10" - ] - }, "www": { "dependencies": [ "npm:@tailwindcss/vite@^4.1.12", diff --git a/packages/test-utils/README.md b/packages/test-utils/README.md deleted file mode 100644 index bfdad634682..00000000000 --- a/packages/test-utils/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# DEPRECATED: @fresh/test-utils - -This package has been replaced by `@fresh/internal/test-utils` and -`@fresh/internal/versions`. - -All consumers have been migrated. This directory will be removed. diff --git a/packages/test-utils/deno.json b/packages/test-utils/deno.json deleted file mode 100644 index 6c389441876..00000000000 --- a/packages/test-utils/deno.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@fresh/test-utils-DEPRECATED", - "private": true, - "version": "0.1.0", - "license": "MIT", - "exports": { - ".": "./src/mod.ts" - }, - "imports": { - "@std/assert": "jsr:@std/assert@^1.0.8", - "@std/async": "jsr:@std/async@^1.0.13", - "@std/expect": "jsr:@std/expect@^1.0.16", - "@std/fs": "jsr:@std/fs@1", - "@std/fmt": "jsr:@std/fmt@^1.0.8", - "@std/path": "jsr:@std/path@1", - "@std/streams": "jsr:@std/streams@1", - "linkedom": "npm:linkedom@^0.18.10", - "@astral/astral": "jsr:@astral/astral@^0.5.3", - "fresh": "jsr:@fresh/core@^2.0.0" - }, - "compilerOptions": { - "lib": ["dom", "dom.asynciterable", "deno.ns", "deno.unstable"], - "jsx": "precompile", - "jsxImportSource": "preact" - } -} diff --git a/packages/test-utils/src/browser.ts b/packages/test-utils/src/browser.ts deleted file mode 100644 index 96f0bfce730..00000000000 --- a/packages/test-utils/src/browser.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/dom.ts b/packages/test-utils/src/dom.ts deleted file mode 100644 index 96f0bfce730..00000000000 --- a/packages/test-utils/src/dom.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/fs.ts b/packages/test-utils/src/fs.ts deleted file mode 100644 index 96f0bfce730..00000000000 --- a/packages/test-utils/src/fs.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/mod.ts b/packages/test-utils/src/mod.ts deleted file mode 100644 index 421ab548845..00000000000 --- a/packages/test-utils/src/mod.ts +++ /dev/null @@ -1 +0,0 @@ -// Deprecated. Use @fresh/internal/test-utils instead. diff --git a/packages/test-utils/src/process.ts b/packages/test-utils/src/process.ts deleted file mode 100644 index 96f0bfce730..00000000000 --- a/packages/test-utils/src/process.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/server.ts b/packages/test-utils/src/server.ts deleted file mode 100644 index 96f0bfce730..00000000000 --- a/packages/test-utils/src/server.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/util.ts b/packages/test-utils/src/util.ts deleted file mode 100644 index 96f0bfce730..00000000000 --- a/packages/test-utils/src/util.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/test-utils diff --git a/packages/test-utils/src/versions.ts b/packages/test-utils/src/versions.ts deleted file mode 100644 index fac5ea76420..00000000000 --- a/packages/test-utils/src/versions.ts +++ /dev/null @@ -1 +0,0 @@ -// DEPRECATED: moved to @fresh/internal/versions From 8c90a15a8f8468da15ebcb9cd4b1ea96f75077b3 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:59:05 +0200 Subject: [PATCH 09/14] test: refactor snapshot #8 --- packages/fresh/src/app.ts | 17 +- packages/fresh/src/app_test.tsx | 2 +- packages/fresh/src/context_test.tsx | 2 +- packages/fresh/src/dev/builder_test.ts | 2 +- .../fresh/src/dev/dev_build_cache_test.ts | 2 +- .../fresh/src/dev/file_transformer_test.ts | 2 +- packages/fresh/src/dev/fs_crawl_test.ts | 2 +- .../error_overlay/middleware_test.tsx | 2 +- packages/fresh/src/dev/update_check_test.ts | 2 +- packages/fresh/src/fs_routes_test.tsx | 3 +- packages/fresh/src/middlewares/mod_test.ts | 2 +- .../src/middlewares/static_files_test.ts | 2 +- .../src/middlewares/trailing_slashes_test.ts | 2 +- packages/fresh/src/test_utils.ts | 193 ----------- packages/fresh/tests/test_utils.tsx | 313 +----------------- 15 files changed, 32 insertions(+), 516 deletions(-) delete mode 100644 packages/fresh/src/test_utils.ts diff --git a/packages/fresh/src/app.ts b/packages/fresh/src/app.ts index b07a2a3eebe..6690182cecd 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,19 @@ 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. +class __InlineFallbackBuildCache implements BuildCache { + root = ""; + clientEntry = ""; + islandRegistry: ServerIslandRegistry = new Map(); + features = { errorOverlay: false }; + getEntryAssets(): string[] { return []; } + // deno-lint-ignore no-explicit-any + 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 +384,7 @@ 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); + buildCache = new __InlineFallbackBuildCache(); } } 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 37e6017ca6e..48fc8bc423e 100644 --- a/packages/fresh/src/context_test.tsx +++ b/packages/fresh/src/context_test.tsx @@ -2,7 +2,7 @@ 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 } from "@fresh/internal/test-utils"; import { BUILD_ID } from "@fresh/build-id"; import { parseHtml } from "@fresh/internal/test-utils"; diff --git a/packages/fresh/src/dev/builder_test.ts b/packages/fresh/src/dev/builder_test.ts index 9504c3d1c5e..91ec26f3366 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -4,7 +4,7 @@ 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 { withTmpDir, writeFiles } from "@fresh/internal/test-utils"; import { getStdOutput, withChildProcessServer, 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 2f8285fb96a..abd41fe7a11 100644 --- a/packages/fresh/src/dev/update_check_test.ts +++ b/packages/fresh/src/dev/update_check_test.ts @@ -2,7 +2,7 @@ import * as path from "@std/path"; import denoJson from "../../deno.json" with { type: "json" }; import { getStdOutput } from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; -import { withTmpDir } from "../test_utils.ts"; +import { withTmpDir } from "@fresh/internal/test-utils"; 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 6c74ae495e6..f28d0252ec4 100644 --- a/packages/fresh/src/fs_routes_test.tsx +++ b/packages/fresh/src/fs_routes_test.tsx @@ -1,7 +1,6 @@ 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 { delay, FakeServer, MockBuildCache, createFakeFs } 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"; 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/src/test_utils.ts b/packages/fresh/src/test_utils.ts deleted file mode 100644 index 70237b15447..00000000000 --- a/packages/fresh/src/test_utils.ts +++ /dev/null @@ -1,193 +0,0 @@ -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"; - -const STUB = {} as unknown as Deno.ServeHandlerInfo; - -export class FakeServer { - constructor( - public handler: ( - req: Request, - info: Deno.ServeHandlerInfo, - ) => Response | Promise, - ) {} - - async get(path: string, init?: RequestInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, init); - return await this.handler(req, STUB); - } - async post(path: string, body?: BodyInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "post", body }); - return await this.handler(req, STUB); - } - async patch(path: string, body?: BodyInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "patch", body }); - return await this.handler(req, STUB); - } - async put(path: string, body?: BodyInit): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "put", body }); - return await this.handler(req, STUB); - } - async delete(path: string): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "delete" }); - return await this.handler(req, STUB); - } - async head(path: string): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "head" }); - return await this.handler(req, STUB); - } - async options(path: string): Promise { - const url = this.toUrl(path); - const req = new Request(url, { method: "options" }); - return await this.handler(req, STUB); - } - - async request(req: Request): Promise { - return await this.handler(req, STUB); - } - - private toUrl(path: string) { - return new URL(path, "http://localhost/"); - } -} - -const DEFAULT_CONFIG: ResolvedFreshConfig = { - root: "", - mode: "production", - basePath: "", -}; - -export function serveMiddleware( - middleware: (ctx: Context) => Response | Promise, - options: { - config?: ResolvedFreshConfig; - buildCache?: BuildCache; - next?: () => Promise; - route?: string | null; - } = {}, -): FakeServer { - return new FakeServer(async (req) => { - const next = options.next ?? - (() => new Response("not found", { status: 404 })); - const config = options.config ?? DEFAULT_CONFIG; - const buildCache = options.buildCache ?? - new MockBuildCache([], options.config?.mode ?? "production"); - - const ctx = new Context( - req, - new URL(req.url), - DEFAULT_CONN_INFO, - options.route ?? null, - {}, - config, - () => Promise.resolve(next()), - buildCache, - ); - return await middleware(ctx); - }); -} - -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 = ""; - clientEntry = ""; - islandRegistry: ServerIslandRegistry = new Map(); - features = { errorOverlay: false }; - - constructor(files: FsRouteFile[], mode: "development" | "production") { - this.features.errorOverlay = mode === "development"; - this.#files = files; - } - - getEntryAssets(): string[] { - return []; - } - - getFsRoutes(): Command[] { - return fsItemsToCommands(this.#files); - } - - readFile(_pathname: string): Promise { - return Promise.resolve(null); - } -} - -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; - } - } - })); -} diff --git a/packages/fresh/tests/test_utils.tsx b/packages/fresh/tests/test_utils.tsx index 99e687a5e01..bfde100c899 100644 --- a/packages/fresh/tests/test_utils.tsx +++ b/packages/fresh/tests/test_utils.tsx @@ -1,23 +1,11 @@ import type { App } from "../src/app.ts"; -import { launch, type Page } from "@astral/astral"; -import * as colors from "@std/fmt/colors"; -import { DOMParser, HTMLElement } from "linkedom"; -import { Builder, type BuildOptions } from "../src/dev/builder.ts"; -import { TextLineStream } from "@std/streams/text-line-stream"; import * as path from "@std/path"; import type { ComponentChildren } from "preact"; -import { expect } from "@std/expect"; -import { mergeReadableStreams } from "@std/streams"; +import { Builder, type BuildOptions } from "../src/dev/builder.ts"; -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", -}); +// Generic helpers like withBrowserApp, withBrowser, parseHtml, waitFor, waitForText +// have been moved to @fresh/internal/test-utils. This file now only contains +// project-specific helpers used by the Fresh package tests. export const charset = ; @@ -61,298 +49,7 @@ export async function buildProd( return await builder.build({ mode: "production", snapshot: "memory" }); } -export async function withBrowserApp( - app: App, - fn: (page: Page, address: string) => void | Promise, -) { - const aborter = new AbortController(); - await using server = Deno.serve({ - hostname: "localhost", - port: 0, - signal: aborter.signal, - onListen: () => {}, // Don't spam terminal with "Listening on..." - }, app.handler()); - - try { - await using page = await browser.newPage(); - await fn(page, `http://localhost:${server.addr.port}`); - } finally { - aborter.abort(); - } -} - -export async function withBrowser(fn: (page: Page) => void | Promise) { - await using page = await browser.newPage(); - try { - await fn(page); - } catch (err) { - const raw = await page.content(); - const doc = parseHtml(raw); - const html = prettyDom(doc); - // deno-lint-ignore no-console - console.log(html); - throw err; - } -} - -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 const VOID_ELEMENTS = - /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/; -function prettyDom(doc: Document) { - let out = colors.dim(`\n`); - - const node = doc.documentElement; - out += _printDomNode(node, 0); - - return out; -} - -function _printDomNode( - node: HTMLElement | Text | Node, - indent: number, -) { - const space = " ".repeat(indent); - - if (node.nodeType === 3) { - return space + colors.dim(node.textContent ?? "") + "\n"; - } else if (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]; - 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({}); From c1895f8331c12d4b9827b3c36eefd823df8291b0 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 23:08:28 +0200 Subject: [PATCH 10/14] docs: update design doc --- design/current-test-suite.md | 83 +++++++++++++++++------------------- 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/design/current-test-suite.md b/design/current-test-suite.md index 57096ed3227..17764ed05d2 100644 --- a/design/current-test-suite.md +++ b/design/current-test-suite.md @@ -23,28 +23,26 @@ The core of the testing strategy revolves around: ## 2. Key Files and Components -- **`packages/fresh/tests/test_utils.tsx`**: This is the main utility file for - the `fresh` package tests. It contains functions for: - - Launching a headless browser (`withBrowser`, `withBrowserApp`). - - Spawning a child process for a dev server and waiting for it to be ready by - parsing its stdout (`withChildProcessServer`). - - Building a Fresh application for production (`buildProd`). - - Parsing and asserting HTML content (`parseHtml`, `assertSelector`, etc.). - - It also contains JSX components (`Doc`, `favicon`) used for test setup, - which is a problematic mixing of concerns. - -- **`packages/plugin-vite/tests/test_utils.ts`**: This file contains utilities - specifically for testing the Vite plugin. It **re-imports and uses functions - from `packages/fresh/tests/test_utils.tsx`**, notably - `withChildProcessServer`. It also introduces its own logic for: - - Creating temporary directories for test fixtures (`prepareDevServer`). - - Copying fixture projects into these temporary directories. - - Launching and managing dev servers specifically for Vite-based projects - (`launchDevServer`, `spawnDevServer`). - -- **`packages/fresh/src/test_utils.ts`**: This file contains lower-level - utilities, including the `withTmpDir` function, which is the source of the - temporary directory problem. +- 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/` @@ -52,7 +50,7 @@ The core of the testing strategy revolves around: - `packages/plugin-vite/tests/fixtures/` - These contain the small Fresh projects that are run during tests. -## 3. Problematic Areas +## 3. Problematic Areas (pre-refactor) ### 3.1. Temporary Directory Management @@ -97,23 +95,14 @@ The core of the testing strategy revolves around: ### 3.4. Cross-Package Dependencies -- **The Problem:** `plugin-vite`'s test suite has a hard dependency on the test - utilities of the `fresh` package - (`import ... from "../../fresh/tests/test_utils.tsx"`). -- **Impact:** This is a significant architectural smell. Test utilities for one - package should not be tightly coupled to another. It makes the packages harder - to maintain and test in isolation. It also forces developers to understand the - internals of two packages to work on the tests for one. +- 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 -- **The Problem:** `packages/fresh/tests/test_utils.tsx` is a `.tsx` file and - contains JSX components (`Doc`, `favicon`). -- **Impact:** This mixes test infrastructure logic with view-layer components. - Test utilities should be pure logic (TypeScript modules) and should not be - concerned with rendering. This makes the file harder to read and maintain, and - it creates an unnecessary dependency on Preact/JSX for what should be a - generic utility module. +- 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 @@ -125,12 +114,16 @@ The core of the testing strategy revolves around: difficult to see the "what" (the test's intent) because of all the "how" (the setup and teardown mechanics). -## 4. Conclusion +## 4. Current State (post-refactor) -The current test suite is functional but suffers from several major -architectural flaws that make it brittle, difficult to maintain, and hard to -reason about. The duplication of logic, tight coupling between packages, and -fragile implementation details (like stdout scraping and in-repository temporary -folders) are the primary sources of the pain points described by the user. A -significant refactoring is required to address these issues and create a robust -and scalable testing framework for the Fresh ecosystem. +- 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`. From 6c86e0ab9ebaa833c036f2abf7978c81a5b27a31 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 23:30:32 +0200 Subject: [PATCH 11/14] test: fix overlay regression --- packages/fresh/src/app.ts | 43 +++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/fresh/src/app.ts b/packages/fresh/src/app.ts index 6690182cecd..17db0511004 100644 --- a/packages/fresh/src/app.ts +++ b/packages/fresh/src/app.ts @@ -30,14 +30,43 @@ import { newRouteCmd, } from "./commands.ts"; // Minimal fallback BuildCache for handler() when no build cache is present in dev/test. -class __InlineFallbackBuildCache implements BuildCache { +// +// 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: false }; - getEntryAssets(): string[] { return []; } - // deno-lint-ignore no-explicit-any - getFsRoutes(): Command[] { return []; } + 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); } @@ -384,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 __InlineFallbackBuildCache(); + // In dev/test fallback, enable error overlay feature to keep behavior + // consistent with MockBuildCache used in tests. + buildCache = new __InlineFallbackBuildCache(this.config.mode); } } From fea649ed5be23e6fdab95d64ecc8b157e65e70e7 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Fri, 19 Sep 2025 23:34:34 +0200 Subject: [PATCH 12/14] chore: formatting --- design/current-test-suite.md | 34 +++++++++++++++++---------- packages/fresh/src/fs_routes_test.tsx | 7 +++++- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/design/current-test-suite.md b/design/current-test-suite.md index 17764ed05d2..2c8f74d8e93 100644 --- a/design/current-test-suite.md +++ b/design/current-test-suite.md @@ -27,22 +27,28 @@ The core of the testing strategy revolves around: - `@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` + - DOM helpers: `parseHtml`, `assertSelector`, `assertNotSelector`, + `assertMetaContent` - Process helpers: `withChildProcessServer`, `getStdOutput` - - Server helpers: `FakeServer`, `serveMiddleware`, `MockBuildCache`, `createFakeFs` + - 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: +- `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`. + - 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`. + - Adds Vite-specific helpers like `prepareDevServer`, `launchDevServer`, + `spawnDevServer`, and `buildVite`. - **Fixture Directories**: - `packages/fresh/tests/fixtures_islands/` @@ -95,14 +101,15 @@ The core of the testing strategy revolves around: ### 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`. +- 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`). +- 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 @@ -122,8 +129,9 @@ The core of the testing strategy revolves around: - `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. +- `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/fs_routes_test.tsx b/packages/fresh/src/fs_routes_test.tsx index f28d0252ec4..74047cae0ea 100644 --- a/packages/fresh/src/fs_routes_test.tsx +++ b/packages/fresh/src/fs_routes_test.tsx @@ -1,6 +1,11 @@ import { App, setBuildCache } from "./app.ts"; import { type FreshFsMod, sortRoutePaths } from "./fs_routes.ts"; -import { delay, FakeServer, MockBuildCache, createFakeFs } from "@fresh/internal/test-utils"; +import { + createFakeFs, + delay, + FakeServer, + MockBuildCache, +} 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"; From 31960ffdf3c811159f98a7d20011c248c2dc16c5 Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Sat, 20 Sep 2025 00:16:01 +0200 Subject: [PATCH 13/14] test: consolidate imports --- packages/fresh/src/context_test.tsx | 3 +-- packages/fresh/src/dev/builder_test.ts | 3 ++- packages/fresh/src/dev/update_check_test.ts | 3 +-- packages/fresh/src/fs_routes_test.tsx | 2 +- packages/fresh/tests/active_links_test.tsx | 2 +- packages/fresh/tests/head_test.tsx | 8 ++++++-- packages/fresh/tests/islands_test.tsx | 2 +- packages/fresh/tests/partials_test.tsx | 2 +- packages/init/src/init_test.ts | 2 +- 9 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/fresh/src/context_test.tsx b/packages/fresh/src/context_test.tsx index 48fc8bc423e..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 "@fresh/internal/test-utils"; +import { FakeServer, parseHtml } from "@fresh/internal/test-utils"; import { BUILD_ID } from "@fresh/build-id"; -import { parseHtml } from "@fresh/internal/test-utils"; 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 91ec26f3366..ff3e7ac7ae4 100644 --- a/packages/fresh/src/dev/builder_test.ts +++ b/packages/fresh/src/dev/builder_test.ts @@ -4,10 +4,11 @@ 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 "@fresh/internal/test-utils"; import { getStdOutput, withChildProcessServer, + withTmpDir, + writeFiles, } from "@fresh/internal/test-utils"; import { staticFiles } from "../middlewares/static_files.ts"; diff --git a/packages/fresh/src/dev/update_check_test.ts b/packages/fresh/src/dev/update_check_test.ts index abd41fe7a11..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 "@fresh/internal/test-utils"; +import { getStdOutput, withTmpDir } from "@fresh/internal/test-utils"; import { expect } from "@std/expect"; -import { withTmpDir } from "@fresh/internal/test-utils"; 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 74047cae0ea..e169d2108e1 100644 --- a/packages/fresh/src/fs_routes_test.tsx +++ b/packages/fresh/src/fs_routes_test.tsx @@ -5,12 +5,12 @@ import { 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 "@fresh/internal/test-utils"; import type { Context } from "./context.ts"; import { HttpError } from "./error.ts"; import { crawlRouteDir } from "./dev/fs_crawl.ts"; diff --git a/packages/fresh/tests/active_links_test.tsx b/packages/fresh/tests/active_links_test.tsx index 839331be131..ae1ec0e76f7 100644 --- a/packages/fresh/tests/active_links_test.tsx +++ b/packages/fresh/tests/active_links_test.tsx @@ -2,11 +2,11 @@ import { App, staticFiles } from "fresh"; import { assertNotSelector, assertSelector, + FakeServer, parseHtml, withBrowserApp, } from "@fresh/internal/test-utils"; import { ALL_ISLAND_DIR, buildProd, Doc } from "./test_utils.tsx"; -import { FakeServer } from "@fresh/internal/test-utils"; import { Partial } from "fresh/runtime"; const allIslandCache = await buildProd({ islandDir: ALL_ISLAND_DIR }); diff --git a/packages/fresh/tests/head_test.tsx b/packages/fresh/tests/head_test.tsx index a37341ac93d..170b1a498df 100644 --- a/packages/fresh/tests/head_test.tsx +++ b/packages/fresh/tests/head_test.tsx @@ -1,9 +1,13 @@ import { App, staticFiles } from "fresh"; import { Head } from "fresh/runtime"; -import { parseHtml, waitFor, withBrowserApp } from "@fresh/internal/test-utils"; +import { + FakeServer, + parseHtml, + waitFor, + withBrowserApp, +} from "@fresh/internal/test-utils"; import { buildProd } from "./test_utils.tsx"; import { expect } from "@std/expect"; -import { FakeServer } from "@fresh/internal/test-utils"; import * as path from "@std/path"; Deno.test("Head - ssr - updates title", async () => { diff --git a/packages/fresh/tests/islands_test.tsx b/packages/fresh/tests/islands_test.tsx index 39ecc284e26..ac20b3551b7 100644 --- a/packages/fresh/tests/islands_test.tsx +++ b/packages/fresh/tests/islands_test.tsx @@ -12,6 +12,7 @@ import { JsxChildrenIsland } from "./fixtures_islands/JsxChildrenIsland.tsx"; import { NodeProcess } from "./fixtures_islands/NodeProcess.tsx"; import { signal } from "@preact/signals"; import { + FakeServer, parseHtml, waitForText, withBrowserApp, @@ -28,7 +29,6 @@ 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 "@fresh/internal/test-utils"; import { PARTIAL_SEARCH_PARAM } from "../src/constants.ts"; import { ComputedSignal } from "./fixtures_islands/Computed.tsx"; import { EnvIsland } from "./fixtures_islands/EnvIsland.tsx"; diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx index 9f5f058f3be..b59d911c7d4 100644 --- a/packages/fresh/tests/partials_test.tsx +++ b/packages/fresh/tests/partials_test.tsx @@ -1,6 +1,7 @@ import { assertMetaContent, assertNotSelector, + FakeServer, parseHtml, waitFor, waitForText, @@ -19,7 +20,6 @@ import { import { SelfCounter } from "./fixtures_islands/SelfCounter.tsx"; import { expect } from "@std/expect"; import { PartialInIsland } from "./fixtures_islands/PartialInIsland.tsx"; -import { FakeServer } from "@fresh/internal/test-utils"; import { JsonIsland } from "./fixtures_islands/JsonIsland.tsx"; import { OptOutPartialLink } from "./fixtures_islands/OptOutPartialLink.tsx"; import * as path from "@std/path"; diff --git a/packages/init/src/init_test.ts b/packages/init/src/init_test.ts index 485a63d9863..54762cee910 100644 --- a/packages/init/src/init_test.ts +++ b/packages/init/src/init_test.ts @@ -12,8 +12,8 @@ import { waitForText, withBrowser, withChildProcessServer, + withTmpDir as withTmpDirBase, } from "@fresh/internal/test-utils"; -import { withTmpDir as withTmpDirBase } from "@fresh/internal/test-utils"; import { stub } from "@std/testing/mock"; // deno-lint-ignore no-explicit-any From e3ac9281d367b55d9bb2b6e95e075396ca74e08e Mon Sep 17 00:00:00 2001 From: fry69 <142489379+fry69@users.noreply.github.com> Date: Sat, 20 Sep 2025 00:36:33 +0200 Subject: [PATCH 14/14] docs: remove unrelated docs --- agents.md | 59 ----------- design/new-test-suite-proposal.md | 167 ------------------------------ 2 files changed, 226 deletions(-) delete mode 100644 agents.md delete mode 100644 design/new-test-suite-proposal.md diff --git a/agents.md b/agents.md deleted file mode 100644 index 01eb41b22ab..00000000000 --- a/agents.md +++ /dev/null @@ -1,59 +0,0 @@ -# Fresh - -Fresh is a next-generation web framework for Deno. It is designed to be fast, -reliable, and simple. - -Remember this is Fresh 2, which uses Vite by default. - -Please make yourself familiar with Fresh 2 and the changes compared to Fresh 1 -here: https://fresh.deno.dev/docs/latest/examples/migration-guide - -## Project Overview - -This repository contains the source code for the Fresh framework, as well as the -official website for the project. The repository is a monorepo, with the -framework code located in the `packages` directory and the website code in the -`www` directory. - -The Fresh framework is built on top of Preact and uses a file-system-based -routing system. It also has built-in support for TypeScript and JSX. The -framework is designed to be as lightweight as possible, with a focus on -performance and ease of use. - -The website is a Fresh application that serves as the documentation and showcase -for the framework. It is a good example of how to build a real-world application -with Fresh. - -## Building and Running - -The project uses `deno task` for scripting. The following commands are -available: - -- `deno task test`: Runs the test suite. -- `deno task www`: Starts the development server for the website. -- `deno task build-www`: Builds the website for production. -- `deno task check:types`: Type-checks the code. -- `deno task ok`: Runs a comprehensive check that includes formatting, linting, - type-checking, and tests. - -## Development Conventions - -The project follows the standard Deno conventions. All code is written in -TypeScript and formatted with `deno fmt`. The project also uses `deno lint` to -enforce a consistent coding style. - -The project has a comprehensive test suite that is run on every commit. All new -features and bug fixes should be accompanied by tests. - -The project uses a monorepo structure, with each package located in its own -directory under the `packages` directory. The website is also a package, located -in the `www` directory. - -The project uses a file-system-based routing system. Each route is a file in the -`routes` directory. The file name determines the route's path. - -The project uses Preact for the view layer. All components are written in JSX. - -The project uses islands for client-side interactivity. An island is a component -that is rendered on the server and then hydrated on the client. This allows for -a fast first-page load, while still providing a rich interactive experience. diff --git a/design/new-test-suite-proposal.md b/design/new-test-suite-proposal.md deleted file mode 100644 index 007f601ff85..00000000000 --- a/design/new-test-suite-proposal.md +++ /dev/null @@ -1,167 +0,0 @@ -# Proposal for a New Test Suite Architecture - -This document outlines a plan to refactor the Fresh test suite to address the -problems identified in the analysis document. The goal is to create a robust, -maintainable, and easy-to-understand testing framework. - -## 1. Guiding Principles - -- **Centralization:** Shared testing logic should live in one place. -- **Abstraction:** Hide implementation details, expose clear intent. -- **Robustness:** Eliminate fragile patterns like stdout scraping. -- **Isolation:** Packages should not have test-level dependencies on each other. -- **Clarity:** Code should be easy to read and reason about. - -## 2. The `internal` Package - -I will create a new package at `packages/internal`. This package will **not** be -published to `deno.land/x` and will serve as a centralized home for all shared -testing utilities. - -### 2.1. Directory Structure - -``` -packages/ -└── internal/ - ├── deno.json - ├── README.md - ├── src/ - │ ├── server.ts # Test server management class (TestServer) - │ ├── fixture.ts # Fixture management utilities - │ ├── browser.ts # Headless browser utilities - │ └── asserts.ts # Common assertions (DOM, etc.) - └── fixtures/ # (Optional) Truly shared test fixtures -``` - -### 2.2. Dependencies - -The `deno.json` for `packages/internal` will explicitly list all testing-related -dependencies (`@astral/astral`, `linkedom`, `@std/expect`, etc.). Other packages -will then have a development-only dependency on the `internal` package via a -`file:` reference in their `deno.json`. - -## 3. Core Abstractions - -Instead of a collection of disconnected functions, we will introduce classes and -well-defined interfaces to manage the testing lifecycle. - -### 3.1. `TestServer` Class (`packages/internal/src/server.ts`) - -This class will be the cornerstone of the new architecture. It will encapsulate -all the logic related to starting, managing, and stopping a test server. - -**Proposed `TestServer` API:** - -```typescript -interface TestServerOptions { - fixture: string; // Path to the fixture directory - // ... other options like env vars -} - -class TestServer implements AsyncDisposable { - readonly address: string; // The server's base URL - readonly projectRoot: string; // The temporary directory for the fixture - - constructor(options: TestServerOptions); - - static async create(options: TestServerOptions): Promise; - - // For simple fetch-based tests - fetch(path: string, init?: RequestInit): Promise; - - // For E2E tests - async withPage(fn: (page: Page) => Promise): Promise; - - // To stop the server - async [Symbol.asyncDispose](): Promise; -} -``` - -**Key Improvements:** - -1. **Robust Readiness Check:** The `TestServer.create` factory method will - implement a robust health-check loop. It will repeatedly attempt to `fetch` a - well-known endpoint (e.g., `/`) on the server until it receives a successful - response or times out. This completely eliminates the fragile stdout - scraping. -2. **Unified Interface:** It provides a single, clear entry point for running - any kind of test against a server, whether it's a simple `fetch` or a - full-blown browser test. -3. **Resource Management:** By implementing `AsyncDisposable`, we can use the - `await using` syntax in tests to guarantee that the server process and - temporary directories are cleaned up, even if the test fails. - -### 3.2. Fixture Management (`packages/internal/src/fixture.ts`) - -This module will handle the creation and management of temporary project -directories for tests. - -**The Temporary Directory Problem:** - -The core issue is that fixtures need access to the root `deno.json` and -potentially other files to resolve dependencies correctly. Simply copying a -fixture to `/tmp` breaks these relative paths. - -**Proposed Solution:** - -1. **Default to OS Temp:** The new `createTemporaryFixture` function will, by - default, create temporary directories in the OS's standard temp location - (e.g., `Deno.makeTempDir()`). -2. **Intelligent Copying/Symlinking:** To solve the dependency issue, the - function will: a. Copy the specific fixture files (e.g., from - `packages/plugin-vite/tests/fixtures/my-app`) into the temporary directory. - b. Create a **symlink** from `deno.json` in the temporary directory back to - the repository's root `deno.json` - (`ln -s /path/to/repo/deno.json /tmp/test-123/deno.json`). c. This approach - keeps the test environment isolated while ensuring that Deno's dependency - resolution works as expected. We can extend this to symlink other necessary - root-level files or directories if needed. - -This strategy provides the best of both worlds: clean, isolated test runs -outside the project tree, without breaking the build tooling. - -## 4. Refactoring Plan - -1. **Phase 1: Create the `internal` Package** - - Set up the directory structure for `packages/internal`. - - Move all testing dependencies into its `deno.json`. - - Create the initial `TestServer` class and fixture management utilities, - implementing the robust health check and temporary directory strategies. - -2. **Phase 2: Refactor `packages/plugin-vite` Tests** - - Update `packages/plugin-vite/deno.json` to depend on the local `internal` - package. - - Rewrite `packages/plugin-vite/tests/test_utils.ts` to be a thin wrapper, if - needed at all. Most logic should be imported directly from `internal`. - - Refactor the actual tests (e.g., `dev_server_test.ts`) to use the new - `TestServer` class (`await using server = await TestServer.create(...)`). - - This will eliminate the cross-package import to `fresh` and the - in-repository temporary folders. - -3. **Phase 3: Refactor `packages/fresh` Tests** - - Update `packages/fresh/deno.json` to depend on the `internal` package. - - Delete `packages/fresh/tests/test_utils.tsx`. - - Move any reusable, non-JSX utility functions (like `parseHtml`, - `assertSelector`) to `packages/internal/src/asserts.ts`. - - Move the JSX helper components (`Doc`, `favicon`) into a dedicated file - within the `packages/fresh/tests` directory, but **not** a general-purpose - utility file. They are specific to the tests that use them. - - Refactor the tests (e.g., `islands_test.tsx`) to use the `TestServer` from - the `internal` package. - -4. **Phase 4: Fixture Consolidation** - - Analyze all fixtures across all packages. - - If any fixture is used by tests in more than one package, move it to - `packages/internal/fixtures`. - - Fixtures used only by a single package will remain in that package's test - directory. - -## 5. Conclusion - -This plan systematically dismantles the current tangled test suite and replaces -it with a modern, centralized, and robust architecture. By introducing the -`internal` package and the `TestServer` abstraction, we can eliminate redundant -code, fix brittle implementation details, and make the entire testing process -easier to understand and maintain. The proposed solution for the temporary -directory problem provides a path to clean up the repository without breaking -the underlying build and dependency resolution mechanisms.