diff --git a/docs/site/en/guides/execution.md b/docs/site/en/guides/execution.md index 28103d0..adab7eb 100644 --- a/docs/site/en/guides/execution.md +++ b/docs/site/en/guides/execution.md @@ -50,7 +50,7 @@ export default { - With artifact cleanup enabled, the order is: clean the target output directory, prepare if needed, run the target, then deliver artifacts. Later variants may still clean or move the output directory. Keep reusable preparation files outside target output directories; generate per-build output files in the target command array rather than in one-time project preparation. - `target.prepare: false` skips preparation only for that target; later eligible targets still trigger it. Failure or cancellation stops execution before subsequent targets start. - Commands run in the project root with global configuration, selected dotenv, and Shell values, without merging product env or injecting product/variant identity. They do not inherit a target's default NODE_ENV; global/external values are preserved. Matrix injects `MATRIX_PROJECT`, `MATRIX_ENV_NAME`, and `MATRIX_TARGET=prepare`. -- `matrix prepare [product]` explicitly prepares projects referenced by the selected product (all products if omitted), then generates runtime types without running business targets. Build-plugin type generation is unchanged. +- `matrix prepare [product]` prepares projects referenced by the selected product (all products if omitted), then generates runtime declarations without running business targets. See [type preparation](./integrations.md#type-preparation) for shared-project collection. - `matrix plan` lists preparation commands, working directories, environments, and `beforeTask` in its JSON `preparations` field. `doctor` performs static preflight checks. Neither executes preparation. `project.prepare` is separate from an ordinary target named `prepare`. Preparation never recursively prepares itself and does not support dependencies, continuous services, or artifact settings. diff --git a/docs/site/en/guides/integrations.md b/docs/site/en/guides/integrations.md index c25bdf6..8f942bc 100644 --- a/docs/site/en/guides/integrations.md +++ b/docs/site/en/guides/integrations.md @@ -103,15 +103,23 @@ electron-vite must support the selected Vite version. Type preparation and real ## Type preparation -Run `matrix prepare` before starting development to generate runtime declarations for the selected products. Scope stays in the build plugin configuration; it does not need to be repeated in `matrix.config`. +Run `matrix prepare [product]` to generate declarations without running business targets. The product selects projects; each shared project + scope includes all referring products and variants in the selected `--env`, never other environments. Projects with equal or overlapping physical roots are collected together. `matrix dev` and `matrix build` collect after project preparation, before starting the target. -- By default, preparation checks files directly in the project root only: `electron.vite.config.*` takes priority over `vite.config.*`. It never searches subdirectories or parent directories. Multiple files matching the selected rule require an explicit choice. The locally installed host resolves the real configuration, including plugin hooks, `root`, `envDir`, `envPrefix`, `scope`, and `types` options. Electron main/preload declarations are generated separately, without starting Electron, a dev server, or a bundle build. -- Set `projects.desktop.configFile`, for example `'config/electron.vite.config.ts'`, to select a file relative to that project's root. This overrides automatic discovery; the basename must still match a supported rule (`electron.vite.config.*` or `vite.config.*`, with a JS/TS module extension). Missing, unsupported, or failing explicit configs are errors, never a reason to fall back. `configFile` only controls preparation and does not modify target commands; no separate host setting is needed. -- Preparation resolves the development configuration (`serve`; Electron main/preload use their normal development-time `build` configuration hooks), with `NODE_ENV=development` and `mode` set to the Matrix `--env` selection. Each product/variant is evaluated in an isolated process with `MATRIX_TARGET=prepare` and its product key, project, and variant. Product identity (ID/name/slug/appId, including suffixes and overrides) and version use the same resolution rules as execution plans; a missing package version is allowed when no version is configured. This is not a production build context; keep scope and type contracts independent of the selected product and target. -- Shared projects merge the selected products' public field names. Schema-declared fields do not need runtime values to generate their types. Duplicate scopes, conflicting outputs/prefixes, changing scope sets, and configuration failures are diagnosed before Matrix writes declarations. Preparation does execute configuration code and configuration hooks, in addition to the existing project preparation commands; it is not a side-effect-free static scan. Each host evaluation has a 30-second timeout. -- Projects with neither an explicit config nor a supported project-root config retain the generic `.matrix/types/matrix-runtime.d.ts` generation from Matrix configuration and public environment keys. Other hosts and arbitrary config basenames are not supported yet. A successfully resolved config without a Matrix plugin also retains this generic generation (using Matrix settings, not host-specific prefixes or scopes); an active plugin with `types: false` disables generation. Existing stale declaration files are not automatically deleted. +### Configuration and compatibility -Preparation and builds generate `ImportMetaEnv` and `matrix.config` declarations without environment values. `matrix prepare` honors `types: false` and runs even in Vitest. For build-time defaults and explicit overrides, see [plugin options](../reference/plugins.md#plugin-options). Add the generated directory to each project's `tsconfig.json`: +- Discovery checks only the project root: `electron.vite.config.*` takes priority over `vite.config.*`. Multiple matches require an explicit choice. Set `projects.desktop.configFile` (for example, `'config/electron.vite.config.ts'`) relative to the project root, using a supported JS/TS config basename. Missing, unsupported, or failing explicit configs are errors. This setting does not change target commands; scope stays in the build plugin config. +- **Output boundary:** the resolved host root and declaration output must stay within `project.root`, including through symlinks; escaping or dangling links fail before writes. To migrate, set `project.root` to the shared host directory and use `configFile` for a config stored elsewhere. Nested host roots and internal custom outputs work; external outputs are rejected. Standalone explicit generation is unchanged. +- Shared collection supports standard Vite/electron-vite dev/build targets. Custom targets and other hosts retain existing plugin behavior. Collection does not parse target command flags such as `--mode` or `--config`; an active plugin that adds uncollected fields or changes scope, prefixes, output, or schema fails rather than replacing the contract. +- Explicit `matrix prepare` falls back to Matrix settings and public environment keys in `.matrix/types/matrix-runtime.d.ts` when no supported config exists or a resolved config has no Matrix plugin. This fallback cannot discover host-specific prefixes or outputs. + +### Contract behavior + +- The locally installed host resolves each product/variant in both dev (`MATRIX_TARGET=dev`, `command=serve`, `NODE_ENV=development`) and build (`MATRIX_TARGET=build`, `command=build`, `NODE_ENV=production`) contexts. Configured `nodeEnv` overrides apply; `mode` is the selected Matrix environment. Electron main/preload use normal development-time build hooks. Product identity and version follow execution-plan rules. +- Configuration code and hooks run for both contexts, adding startup work, with a 30-second timeout per evaluation. Collection itself starts no Electron process, server, or bundle build. +- Inferred public fields missing from some products, variants, or contexts become optional. Explicit schema types, defaults, optionality, and validation are unchanged; schema fields need no runtime values. Runtime values remain active-product-only, respecting public prefixes and Electron scope boundaries. +- Duplicate scopes, conflicting outputs/prefixes, changing scope sets, and configuration failures are rejected before writing: across all affected projects for `prepare`, or per collection group for dev/build. Contracts are rebuilt from current sources, so removed fields/products and empty configurations remove obsolete fields. Obsolete output files are not deleted automatically. + +Declarations contain `ImportMetaEnv` and `matrix.config` types, never environment values. `types: false` disables generation. Explicit `matrix prepare` runs even in Vitest; automatic dev/build collection follows the [plugin defaults and explicit opt-ins](../reference/plugins.md#plugin-options), including Vitest suppression and missing-context safeguards. Add the generated directory to each project's `tsconfig.json`: ```json { diff --git a/docs/site/en/reference/cli.md b/docs/site/en/reference/cli.md index 2a6f3bb..d0dbee0 100644 --- a/docs/site/en/reference/cli.md +++ b/docs/site/en/reference/cli.md @@ -42,6 +42,8 @@ matrix prepare [product] [--env ] matrix [product] [--env ] ``` +Shared-project declaration generation for `prepare`, `dev`, and `build` is described in [type preparation](../guides/integrations.md#type-preparation). + ## Static preflight `matrix doctor [--env ]` checks all configured products, variants, and targets in the selected environment without running target/preparation commands, probing ports, generating types, or cleaning output. Configuration loading still evaluates the configuration file as usual. diff --git a/docs/site/en/reference/configuration.md b/docs/site/en/reference/configuration.md index e22a115..62ca1c7 100644 --- a/docs/site/en/reference/configuration.md +++ b/docs/site/en/reference/configuration.md @@ -21,12 +21,12 @@ Use `matrix.config.ts` in the workspace root, export `defineMatrixConfig({...})` Preparation behavior is in [execution](../guides/execution.md#project-preparation). Supported host filenames and type generation are in [integration](../guides/integrations.md#type-preparation). Changing `configFile` does not change target commands. -| Field | Type / default | Purpose | -| ------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -| `root` | String; `.` | Project directory relative to the workspace root; commands run here. | -| `targets` | Target map; required | Project commands; built-in names supply defaults, not commands. | -| `prepare` | String or non-empty string array; optional | Finite preparation commands, once per project per invocation. | -| `configFile` | String; optional | Host config relative to the project root, loaded by `matrix prepare` and checked statically by `matrix doctor`. | +| Field | Type / default | Purpose | +| ------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `root` | String; `.` | Project directory relative to the workspace root; commands run here. | +| `targets` | Target map; required | Project commands; built-in names supply defaults, not commands. | +| `prepare` | String or non-empty string array; optional | Finite preparation commands, once per project per invocation. | +| `configFile` | String; optional | Host config relative to the project root, loaded for prepare/dev/build type collection and checked statically by `matrix doctor`. | ## Products diff --git a/docs/site/en/reference/plugins.md b/docs/site/en/reference/plugins.md index 28c9ae6..51ccdb4 100644 --- a/docs/site/en/reference/plugins.md +++ b/docs/site/en/reference/plugins.md @@ -26,6 +26,8 @@ Pass these options to `matrix()` from the Vite, Rollup, Webpack, or esbuild entr **Default behavior change:** without Matrix CLI context, automatic generation leaves declarations untouched, creates no output directory, and warns once per plugin instance. This applies to all adapters. Run the target through Matrix or use `matrix prepare`. Runtime configuration may be incomplete; opting into type generation does not restore missing context. +For shared-project declarations in Matrix CLI dev/build, see [type preparation](../guides/integrations.md#type-preparation). + Relative output paths resolve from Vite's resolved root or other adapters' working directory. The default is `.matrix/types/matrix-runtime.d.ts`, or `matrix-runtime-.d.ts` in the same directory for scoped builds. Include custom outputs in the project's `tsconfig.json`. ## Runtime fields diff --git a/docs/site/zh-CN/guides/execution.md b/docs/site/zh-CN/guides/execution.md index d582647..3fa8225 100644 --- a/docs/site/zh-CN/guides/execution.md +++ b/docs/site/zh-CN/guides/execution.md @@ -50,7 +50,7 @@ export default { - 启用产物清理时,顺序为“清理目标输出目录 → 按需 prepare → 执行 target → 交付产物”。后续变体仍可能清理或移动输出目录,因此跨变体复用的准备文件应放在 target 输出目录之外;每次构建都需要生成的输出文件应放入 target 命令数组,而不是一次性的项目准备。 - `target.prepare: false` 只跳过当前目标,不影响后续其他目标的准备需求。准备失败或取消会停止本次执行,不启动后续目标。 - 准备命令在 project 根目录执行,使用全局配置、当前 dotenv 和 Shell 环境,不合并产品级 env 或注入产品/变体身份。不继承某个目标的默认 NODE_ENV,保留全局/外部环境中的值;注入 `MATRIX_PROJECT`、`MATRIX_ENV_NAME` 和 `MATRIX_TARGET=prepare`。 -- `matrix prepare [product]` 显式准备所选产品引用的项目(省略产品则处理所有产品),成功后生成现有 runtime 类型,不运行业务 target。构建插件的自动类型生成功能保持不变。 +- `matrix prepare [product]` 准备所选产品引用的项目(省略产品则处理所有产品),成功后生成 runtime 声明,不运行业务 target。共享项目收集规则见[类型准备](./integrations.md#类型准备)。 - `matrix plan` 只展示准备步骤:JSON 的 `preparations` 包含命令、工作目录、环境和 `beforeTask`;`doctor` 执行静态预检,两者均不执行准备命令。 `project.prepare` 与名为 `prepare` 的普通 target 是不同概念。准备阶段本身不会递归触发准备,也不支持依赖、持续服务或产物交付选项。 diff --git a/docs/site/zh-CN/guides/integrations.md b/docs/site/zh-CN/guides/integrations.md index 3d1ce03..2922a1d 100644 --- a/docs/site/zh-CN/guides/integrations.md +++ b/docs/site/zh-CN/guides/integrations.md @@ -103,15 +103,23 @@ electron-vite 必须支持所选 Vite。类型准备组合与真实 Electron 应 ## 类型准备 -启动开发前运行 `matrix prepare`,即可为所选 product 生成 runtime 声明。scope 仍放在构建插件配置中,不需要在 `matrix.config` 重复声明。 +运行 `matrix prepare [product]` 可生成声明而不执行业务目标。product 只选择项目;每个共享 project + scope 都包含所选 `--env` 下引用它的所有产品和变体,不扫描其他环境。实际根目录相同或重叠的项目一起收集。`matrix dev` 和 `matrix build` 在项目准备之后、目标启动之前收集。 -- 默认仅检查 project root 中的文件,按 `electron.vite.config.*` → `vite.config.*` 的优先级匹配,不扫描子目录,也不向父目录查找。同一条选中规则匹配多个文件时需要显式选择。prepare 使用项目本地安装的宿主解析真实配置,包括配置钩子、`root`、`envDir`、`envPrefix`、`scope` 和 `types` 选项。Electron main/preload 分别生成声明,不启动 Electron、开发服务器或打包。 -- 可直接设置 `projects.desktop.configFile`,例如 `'config/electron.vite.config.ts'`,路径相对于该 project root,优先于自动发现。文件名仍需符合内置规则(`electron.vite.config.*` 或 `vite.config.*`,使用 JS/TS 模块扩展名)。显式文件不存在、文件名不支持或解析失败时直接报错,不回退。`configFile` 仅控制 prepare,不修改 target 命令;无需额外配置 host。 -- prepare 解析开发态配置(`serve`;Electron main/preload 按开发时实际流程执行 `build` 配置钩子),`NODE_ENV=development`,`mode` 为 Matrix `--env` 选择。每个 product/variant 在独立进程中解析,传入 `MATRIX_TARGET=prepare` 及 product key、project、variant。产品身份(ID/name/slug/appId,含后缀和覆盖)及版本复用执行计划的解析规则;未配置版本时允许 package version 缺失。这不是生产构建上下文;scope 和类型契约应与所选产品及 target 无关。 -- 共享项目合并所选产品的公开字段名;schema 声明的字段不要求提供运行时值。重复 scope、输出/前缀冲突、scope 集合变化或配置解析失败时,Matrix 会在写入声明前报错。prepare 除执行已有项目准备命令外,还会执行配置代码和配置钩子,并不是无副作用的静态扫描。每次宿主解析限时 30 秒。 -- 未显式指定配置且 project root 没有匹配文件时,保留原有通用生成方式,从 Matrix 配置和公开环境变量键生成 `.matrix/types/matrix-runtime.d.ts`。暂不支持其他宿主或任意配置文件名。宿主配置成功解析但未启用 Matrix 插件时,也沿用该通用方式(使用 Matrix 设置,不采用宿主特定前缀或 scope);启用插件并设置 `types: false` 时不生成。不会自动删除旧声明文件。 +### 配置与兼容性 -prepare 和构建插件生成 `ImportMetaEnv`、`matrix.config` 声明时均不写入环境变量值。`matrix prepare` 遵循 `types: false`,不受 Vitest 默认抑制影响。构建时的默认行为和显式开启方式见[插件选项](../reference/plugins.md#插件选项)。将生成目录加入项目的 `tsconfig.json`: +- 只检查项目根目录,按 `electron.vite.config.*` → `vite.config.*` 优先级匹配;多个匹配文件需要显式选择。可设置相对于项目根目录的 `projects.desktop.configFile`(例如 `'config/electron.vite.config.ts'`),文件名须符合支持的 JS/TS 配置规则。显式文件不存在、名称不支持或解析失败时直接报错。此设置不修改 target 命令;scope 仍放在构建插件配置中。 +- **输出边界:**解析后的宿主 root 和声明输出必须位于 `project.root` 内,符号链接也不能越界;越界或悬空链接在写入前报错。迁移时,将 `project.root` 设为共享宿主目录,通过 `configFile` 引用其他位置的配置。支持嵌套宿主 root 和内部自定义输出,拒绝外部输出。独立使用时的显式生成不变。 +- 共享收集支持标准 Vite/electron-vite dev/build 目标;自定义目标和其他宿主保留原有插件行为。收集不解析 target 命令的 `--mode`、`--config` 等参数;实际插件若增加未收集字段或改变 scope、前缀、输出路径或 schema,会报错而非覆盖契约。 +- 显式 `matrix prepare` 在没有支持的配置,或配置解析成功但未启用 Matrix 插件时,回退到 Matrix 设置和公开环境变量键,在 `.matrix/types/matrix-runtime.d.ts` 生成声明。此回退无法发现宿主特定前缀或输出路径。 + +### 契约行为 + +- 项目本地宿主为每个产品/变体解析 dev(`MATRIX_TARGET=dev`、`command=serve`、`NODE_ENV=development`)和 build(`MATRIX_TARGET=build`、`command=build`、`NODE_ENV=production`)两种上下文。遵循配置的 `nodeEnv` 覆盖,`mode` 为所选 Matrix 环境。Electron main/preload 使用正常开发流程的 build 钩子。产品身份和版本沿用执行计划规则。 +- 两种上下文都会执行配置代码和钩子,增加启动开销;每次解析限时 30 秒。收集本身不启动 Electron、服务器或打包。 +- 仅部分产品、变体或上下文存在的推断公开字段标记为 optional。显式 schema 类型、默认值、可选性及校验不变;schema 字段不要求提供运行时值。运行时值仍只属于当前产品,保留公开前缀和 Electron scope 隔离。 +- 重复 scope、输出/前缀冲突、scope 集合变化或配置失败均在写入前报错:`prepare` 覆盖所有受影响项目,dev/build 以收集组为单位。契约从当前来源重建,因此删除字段/产品或清空配置会移除过时字段;不会自动删除废弃的输出文件。 + +声明只包含 `ImportMetaEnv` 和 `matrix.config` 类型,不写入环境值。`types: false` 关闭生成。显式 `matrix prepare` 不受 Vitest 抑制;自动 dev/build 收集遵循[插件默认行为及显式开启方式](../reference/plugins.md#插件选项),包括 Vitest 抑制和缺少上下文的保护。将生成目录加入项目的 `tsconfig.json`: ```json { diff --git a/docs/site/zh-CN/reference/cli.md b/docs/site/zh-CN/reference/cli.md index c3e2705..30112ba 100644 --- a/docs/site/zh-CN/reference/cli.md +++ b/docs/site/zh-CN/reference/cli.md @@ -42,6 +42,8 @@ matrix prepare [product] [--env ] matrix [product] [--env ] ``` +`prepare`、`dev` 和 `build` 的共享项目声明生成见[类型准备](../guides/integrations.md#类型准备)。 + ## 静态预检 `matrix doctor [--env ]` 检查所选环境中的全部产品、变体和目标,不运行目标或准备命令、不探测端口、不生成类型,也不清理输出。配置加载仍会按原有方式执行配置文件。 diff --git a/docs/site/zh-CN/reference/configuration.md b/docs/site/zh-CN/reference/configuration.md index 8f15ff6..16e3039 100644 --- a/docs/site/zh-CN/reference/configuration.md +++ b/docs/site/zh-CN/reference/configuration.md @@ -21,12 +21,12 @@ 准备流程见[执行说明](../guides/execution.md#项目准备),支持的配置文件名与类型生成见[构建工具接入](../guides/integrations.md#类型准备)。修改 `configFile` 不会修改 target 命令。 -| 字段 | 类型/默认值 | 作用 | -| ------------ | ------------------------------- | ----------------------------------------------------------------------------------- | -| `root` | string;`.` | 相对于工作区根目录的项目目录,也是命令工作目录。 | -| `targets` | Target 映射;必填 | 项目命令;内置目标名只提供默认值,不自动提供命令。 | -| `prepare` | string 或非空 string 数组;可选 | 每次调用、每个项目最多执行一次的有限准备命令。 | -| `configFile` | string;可选 | 相对于项目根目录的宿主配置,由 `matrix prepare` 加载,由 `matrix doctor` 静态检查。 | +| 字段 | 类型/默认值 | 作用 | +| ------------ | ------------------------------- | ------------------------------------------------------------------------------------------ | +| `root` | string;`.` | 相对于工作区根目录的项目目录,也是命令工作目录。 | +| `targets` | Target 映射;必填 | 项目命令;内置目标名只提供默认值,不自动提供命令。 | +| `prepare` | string 或非空 string 数组;可选 | 每次调用、每个项目最多执行一次的有限准备命令。 | +| `configFile` | string;可选 | 相对于项目根目录的宿主配置,用于 prepare/dev/build 类型收集,由 `matrix doctor` 静态检查。 | ## Product diff --git a/docs/site/zh-CN/reference/plugins.md b/docs/site/zh-CN/reference/plugins.md index 165cbb3..07b0047 100644 --- a/docs/site/zh-CN/reference/plugins.md +++ b/docs/site/zh-CN/reference/plugins.md @@ -26,6 +26,8 @@ **默认行为变更:**缺少 Matrix CLI 上下文时,所有适配器的自动生成都保留现有声明、不创建输出目录,每个插件实例仅警告一次。请通过 Matrix 启动目标,或运行 `matrix prepare`。运行时配置可能不完整,显式开启类型生成不会补齐上下文。 +Matrix CLI dev/build 的共享项目声明规则见[类型准备](../guides/integrations.md#类型准备)。 + 相对输出路径基于 Vite 最终 root 解析,其他适配器使用当前工作目录。默认文件为 `.matrix/types/matrix-runtime.d.ts`;设置 scope 后为同目录下的 `matrix-runtime-.d.ts`。自定义输出需加入项目的 `tsconfig.json`。 ## Runtime 字段 diff --git a/src/cli/index.ts b/src/cli/index.ts index 04d4aee..00d397f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import type { PreparedTypeContract } from '../typegen/coordination.js' import type { EnvMap } from '../types.js' import process from 'node:process' import consola from 'consola' @@ -7,6 +8,7 @@ import { diagnoseWorkspace } from '../execution/doctor.js' import { runExecutionPlan } from '../execution/exec.js' import { createPreparationPlan } from '../execution/plan.js' import { ExecutionReport } from '../execution/report.js' +import { MATRIX_TYPE_CONTRACT_KEY, preparedTypeDeclaration } from '../typegen/coordination.js' import { TypePreparationCancelled } from '../typegen/prepare.js' import { generateProjectTypes, ProjectTypePreparationError } from '../typegen/project.js' import { CLI_COMMANDS, cliHelp, parseArgs, validateCliArgs } from './args.js' @@ -112,7 +114,34 @@ export async function runCli(argv: string[] = process.argv.slice(2)): Promise() + // Type preparation belongs to this CLI invocation, never to each active plugin. + // Keep project preparation just-in-time: it may create the host configuration. + const result = await runExecutionPlan(selection.plan, { + beforeTask: async (task, signal) => { + delete task.env[MATRIX_TYPE_CONTRACT_KEY] + // Preparation can change shared configs, including another project's inputs. + // This hook runs after it completes; retain caches only between preparations. + if (selection.plan.preparations?.some(preparation => preparation.beforeTask === task.id)) + contracts.clear() + if (task.target !== 'dev' && task.target !== 'build') + return + let contract = contracts.get(task.project) + if (contract === undefined) { + const prepared: PreparedTypeContract = { strict: false, declarations: [] } + await generateProjectTypes(selection.loaded, [], { + projects: [task.project], + automatic: true, + signal, + onHost: () => { prepared.strict = true }, + onDeclaration: declaration => prepared.declarations.push(preparedTypeDeclaration(declaration)), + }) + contract = JSON.stringify(prepared) + contracts.set(task.project, contract) + } + task.env[MATRIX_TYPE_CONTRACT_KEY] = contract + }, + }) if (result.cancelled) process.exitCode = result.cancelled === 'SIGINT' ? 130 : 143 } diff --git a/src/cli/selection.ts b/src/cli/selection.ts index 151ff2c..5917644 100644 --- a/src/cli/selection.ts +++ b/src/cli/selection.ts @@ -13,6 +13,7 @@ const builtinCommands: readonly string[] = Object.values(CLI_COMMANDS) type LoadedConfig = Awaited> export interface Selection { + loaded: LoadedConfig product: string target: string variants: string[] @@ -166,7 +167,7 @@ async function completeSelection(args: Args, screen: SelectionScreen): Promise, cancelled: false | 'SIGINT' | 'SIGTERM' }> { +export async function runExecutionPlan(plan: ExecutionPlan, options: { report?: ExecutionReport, beforeTask?: (task: ExecutionTask, signal: AbortSignal) => Promise } = {}): Promise<{ children: Map, cancelled: false | 'SIGINT' | 'SIGTERM' }> { + for (const task of [...plan.tasks, ...(plan.preparations ?? [])]) + delete task.env[MATRIX_TYPE_CONTRACT_KEY] const report = options.report ?? new ExecutionReport(plan) const children = new Map() const services = new Map>() @@ -93,12 +95,14 @@ export async function runExecutionPlan(plan: ExecutionPlan, options: { report?: } return shutdown } + const controller = new AbortController() let cancelled: false | 'SIGINT' | 'SIGTERM' = false const stopAll = (signal: 'SIGINT' | 'SIGTERM'): void => { if (cancelled) return cancelled = signal report.cancel(signal) + controller.abort(cancellationError) interruptWaits() void stopChildren() } @@ -111,6 +115,10 @@ export async function runExecutionPlan(plan: ExecutionPlan, options: { report?: return await action() } catch (error) { + if (cancelled) + throw cancellationError + if (controller.signal.aborted) + throw controller.signal.reason if (error === cancellationError || error instanceof ExecutionFailure) throw error throw report.fail(context, error) @@ -131,6 +139,8 @@ export async function runExecutionPlan(plan: ExecutionPlan, options: { report?: env: { ...Object.fromEntries(Object.entries(task.env).map(([key, value]) => [key, String(value)])), [MATRIX_ENV_SCHEMA_KEY]: serializeEnvSchema(plan.envSchema), + // Do not inherit another CLI invocation's output handshake. + [MATRIX_TYPE_CONTRACT_KEY]: task.env[MATRIX_TYPE_CONTRACT_KEY] === undefined ? undefined : String(task.env[MATRIX_TYPE_CONTRACT_KEY]), }, extendEnv: true, forceKillAfterDelay: false, @@ -168,7 +178,8 @@ export async function runExecutionPlan(plan: ExecutionPlan, options: { report?: services.set(task.id, service) const failure = service.then(() => neverSettles) interruptions.add(failure) - void failure.catch(() => undefined) + // Abort in-flight preparation too, while its caller still awaits cleanup. + void failure.catch(error => controller.abort(error)) return } @@ -249,6 +260,16 @@ export async function runExecutionPlan(plan: ExecutionPlan, options: { report?: if (cancelled) return { children, cancelled } + if (options.beforeTask) { + await inPhase({ id: task.id, phase: 'preparation', project: task.project }, async () => { + controller.signal.throwIfAborted() + await options.beforeTask!(task, controller.signal) + controller.signal.throwIfAborted() + }) + if (cancelled) + return { children, cancelled } + } + await runCommands(task, { id: task.id, phase: 'command', project: task.project }) if (cancelled) return { children, cancelled } diff --git a/src/runtime/public-env.ts b/src/runtime/public-env.ts index a5aa16e..b521d5d 100644 --- a/src/runtime/public-env.ts +++ b/src/runtime/public-env.ts @@ -1,6 +1,6 @@ import type { EnvSchema, Scalar } from '../types.js' import { camelCase } from 'scule' -import { MATRIX_ENV_SCHEMA_KEY } from '../config/env-schema.js' +import { MATRIX_ENV_SCHEMA_KEY, MATRIX_TYPE_CONTRACT_KEY } from '../config/env-schema.js' import { parseEnvValue } from '../config/schema.js' export type EnvPrefix = string | string[] @@ -31,7 +31,7 @@ export function ensureMatrixEnvPrefix(prefix: EnvPrefix | undefined): string[] { export function publicEnvKeys(env: Record, prefix: EnvPrefix | undefined = ['VITE_', 'MATRIX_']): string[] { const prefixes = normalizeEnvPrefix(prefix) return Object.keys(env).filter((key) => { - if (key.startsWith('MATRIX_') || key === MATRIX_ENV_SCHEMA_KEY || matrixKeys.has(key)) + if (key.startsWith('MATRIX_') || key === MATRIX_ENV_SCHEMA_KEY || key === MATRIX_TYPE_CONTRACT_KEY || matrixKeys.has(key)) return false return prefixes.some(candidate => key.startsWith(candidate)) }).sort() diff --git a/src/typegen/collector.ts b/src/typegen/collector.ts index 1b127e2..b39aa00 100644 --- a/src/typegen/collector.ts +++ b/src/typegen/collector.ts @@ -3,15 +3,19 @@ import type { GenerateMatrixTypesOptions } from './generate.js' // Shared across package copies loaded by the host config, only inside the preparation process. const key = Symbol.for('@xova/matrix/type-preparation') type Collector = (options: GenerateMatrixTypesOptions | undefined) => void -const context = globalThis as typeof globalThis & { [key]?: Collector } +const context = globalThis as typeof globalThis & { [key]?: { collect: Collector, automatic: boolean } } -export function setTypeCollector(collector: Collector): void { - context[key] = collector +export function setTypeCollector(collector: Collector, automatic = false): void { + context[key] = { collect: collector, automatic } } export function collectPreparedTypes(options: GenerateMatrixTypesOptions | undefined): boolean { if (!context[key]) return false - context[key](options) + context[key].collect(options) return true } + +export function isAutomaticTypeCollection(): boolean { + return context[key]?.automatic ?? false +} diff --git a/src/typegen/coordination.ts b/src/typegen/coordination.ts new file mode 100644 index 0000000..d55f6a3 --- /dev/null +++ b/src/typegen/coordination.ts @@ -0,0 +1,56 @@ +import type { GenerateMatrixTypesOptions } from './generate.js' +import { serializeEnvSchema } from '../config/env-schema.js' +import { ensureMatrixEnvPrefix, publicEnvKeys } from '../runtime/public-env.js' +import { matrixTypeOutput } from './generate.js' + +/** Private, value-free handshake between the CLI coordinator and host plugins. */ +export { MATRIX_TYPE_CONTRACT_KEY } from '../config/env-schema.js' + +export interface PreparedTypeContract { + strict: boolean + declarations: Array<{ output: string, signature: string, keys: string[] }> +} + +function signature(options: GenerateMatrixTypesOptions): string { + const schema = JSON.parse(serializeEnvSchema(options.envSchema)) as Record + return JSON.stringify([ + options.scope ?? '', + ensureMatrixEnvPrefix(options.envPrefix), + Object.entries(schema).sort(([a], [b]) => a.localeCompare(b)), + ]) +} + +export function preparedTypeDeclaration(options: GenerateMatrixTypesOptions): PreparedTypeContract['declarations'][number] { + return { + output: matrixTypeOutput(options), + signature: signature(options), + keys: publicEnvKeys({ ...options.envSchema, ...options.env }, ensureMatrixEnvPrefix(options.envPrefix)), + } +} + +/** A plugin may consume the complete contract, but must never silently widen or replace it. */ +export function hasPreparedTypeContract(value: string | undefined, options: GenerateMatrixTypesOptions): boolean { + if (value === undefined) + return false + let contract: PreparedTypeContract + try { + contract = JSON.parse(value) as PreparedTypeContract + if (typeof contract?.strict !== 'boolean' || !Array.isArray(contract.declarations) + || contract.declarations.some(declaration => !declaration || typeof declaration.output !== 'string' + || typeof declaration.signature !== 'string' || !Array.isArray(declaration.keys) + || declaration.keys.some(key => typeof key !== 'string'))) { + throw new Error('Invalid contract') + } + } + catch { + throw new Error('Invalid Matrix internal type contract') + } + // Generic preparation cannot resolve a non-Vite host's plugin options. + if (!contract.strict) + return false + const active = preparedTypeDeclaration(options) + const prepared = contract.declarations.find(declaration => declaration.output === active.output) + if (!prepared || prepared.signature !== active.signature || active.keys.some(key => !prepared.keys.includes(key))) + throw new Error(`Matrix type contract changed after collection for ${active.output}. Keep scope, prefixes, output, and public fields stable across host resolutions, then rerun Matrix.`) + return true +} diff --git a/src/typegen/generate.ts b/src/typegen/generate.ts index cc6a452..f798cb2 100644 --- a/src/typegen/generate.ts +++ b/src/typegen/generate.ts @@ -8,7 +8,13 @@ import { resolveFilesystemPath } from '../utils/path.js' export interface GenerateMatrixTypesOptions { cwd: string + /** Verified owning Matrix project root, which may contain nested host roots. */ + typeProjectRoot?: string env: Record + /** Inferred fields absent from at least one collected product/variant/context. */ + optionalEnvKeys?: string[] + /** Raw keys potentially absent from another collected scope in this project. */ + optionalImportMetaEnvKeys?: string[] envPrefix?: EnvPrefix envSchema?: EnvSchema scope?: string @@ -30,15 +36,34 @@ export function matrixTypeOutput(options: Pick { + const schema = options.envSchema ?? {} + return publicEnvKeys({ ...schema, ...options.env }, ensureMatrixEnvPrefix(options.envPrefix)).map((key) => { + const field = Object.hasOwn(schema, key) ? schema[key] : undefined + return { + key, + optional: field + ? !!field.optional && field.default === undefined + : !!options.optionalEnvKeys?.includes(key), + } + }) +} + /** Render first so preparation can validate every scope before writing declarations. */ export function renderMatrixTypes(options: GenerateMatrixTypesOptions): string { const prefixes = ensureMatrixEnvPrefix(options.envPrefix) const schema = options.envSchema ?? {} - const keys = publicEnvKeys({ ...schema, ...options.env }, prefixes) + const properties = matrixTypeEnvProperties(options) + const keys = properties.map(property => property.key) const fields = publicConfigFields(keys, prefixes, schema) const configNames = [...fields.keys()].filter(Boolean).sort() const field = (key: string): EnvField | undefined => Object.hasOwn(schema, key) ? schema[key] : undefined - const optional = (key: string): string => field(key)?.optional && field(key)?.default === undefined ? '?' : '' + const optionalKeys = new Set(properties.filter(property => property.optional).map(property => property.key)) + const optional = (key: string): string => optionalKeys.has(key) ? '?' : '' + const rawOptionalKeys = options.optionalImportMetaEnvKeys === undefined + ? optionalKeys + : new Set(options.optionalImportMetaEnvKeys) const configType = (key: string): string => { const declaration = field(key) return declaration?.type === 'enum' @@ -52,7 +77,7 @@ export function renderMatrixTypes(options: GenerateMatrixTypesOptions): string { '// oxfmt-ignore', '// Generated by `matrix prepare`. Do not edit manually.', 'interface ImportMetaEnv {', - ...keys.map(key => ` readonly ${key}${optional(key)}: string`), + ...keys.map(key => ` readonly ${key}${rawOptionalKeys.has(key) ? '?' : ''}: string`), '}', '', `declare module '${moduleId}' {`, diff --git a/src/typegen/lock.ts b/src/typegen/lock.ts new file mode 100644 index 0000000..ca5bf63 --- /dev/null +++ b/src/typegen/lock.ts @@ -0,0 +1,121 @@ +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readdir, rmdir, unlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import process from 'node:process' +import { setTimeout } from 'node:timers/promises' +import path from 'pathe' +import { resolveExistingPath } from '../utils/path.js' + +/** Remove only a uniquely identified owner, never a successor's lock directory. */ +async function releaseOwner(lock: string, owner: string): Promise { + // File deletion/rename completion does not elect one winner on every OS. + // Keep a per-owner mkdir claim until the whole directory removal finishes. + const claim = `${lock}.${owner}.releasing` + try { + await mkdir(claim) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') + return false + throw error + } + try { + try { + await unlink(path.join(lock, owner)) + } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return false + throw error + } + // Only the claim holder may remove this directory. Never recursively + // remove a lock: unexpected/new ownership must survive. + await rmdir(lock) + return true + } + finally { + await rmdir(claim) + } +} + +async function recoverDeadOwner(lock: string): Promise { + const entries = await readdir(lock).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') + return [] + throw error + }) + if (entries.length !== 1) + return false + const owner = entries[0]! + const match = /^(\d+)-[\da-f-]{36}\.owner$/.exec(owner) + if (!match) + return false + const pid = Number(match[1]) + if (!Number.isSafeInteger(pid) || pid <= 0) + return false + try { + process.kill(pid, 0) + return false + } + catch (error) { + // EPERM and reused/live PIDs are deliberately not treated as dead owners. + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') + return false + } + return releaseOwner(lock, owner) +} + +/** Serialize collection and publication, not just each atomic file replacement. */ +export async function withTypePreparationLock(cwd: string, action: () => Promise, signal?: AbortSignal): Promise { + const identity = await resolveExistingPath(cwd) + const lock = path.join(os.tmpdir(), `matrix-types-${createHash('sha256').update(identity).digest('hex')}.lock`) + const owner = `${process.pid}-${randomUUID()}.owner` + const deadline = Date.now() + 300_000 + while (true) { + signal?.throwIfAborted() + try { + await mkdir(lock) + break + } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') + throw error + if (await recoverDeadOwner(lock)) + continue + if (Date.now() >= deadline) + throw new Error(`Timed out waiting for Matrix type preparation lock ${lock}. If ownership cannot be verified, remove this directory and its sibling ${path.basename(lock)}.*.releasing claim directories only after confirming no Matrix preparation or recovery is running. An interrupted recovery claim is never stolen automatically.`) + try { + await setTimeout(50, undefined, signal ? { signal } : {}) + } + catch (error) { + signal?.throwIfAborted() + throw error + } + } + } + try { + await writeFile(path.join(lock, owner), '', { flag: 'wx' }) + } + catch (error) { + try { + if (!await releaseOwner(lock, owner)) + await rmdir(lock) + } + catch { + // Preserve the original acquisition error. + } + throw error + } + let result: T + try { + signal?.throwIfAborted() + result = await action() + } + catch (error) { + // Cleanup must not replace the original failure or cancellation reason. + await releaseOwner(lock, owner).catch(() => undefined) + throw error + } + await releaseOwner(lock, owner) + return result +} diff --git a/src/typegen/prepare.ts b/src/typegen/prepare.ts index b590783..9f267ae 100644 --- a/src/typegen/prepare.ts +++ b/src/typegen/prepare.ts @@ -4,9 +4,10 @@ import { fork } from 'node:child_process' import { readdir, stat } from 'node:fs/promises' import process from 'node:process' import path from 'pathe' +import { MATRIX_TYPE_CONTRACT_KEY } from '../config/env-schema.js' import { ensureMatrixEnvPrefix } from '../runtime/public-env.js' -import { resolveFilesystemPath } from '../utils/path.js' -import { matrixRuntimeModuleId, matrixTypeEnvKeys, matrixTypeOutput, renderMatrixTypes } from './generate.js' +import { containsPath, resolveFilesystemPath } from '../utils/path.js' +import { matrixRuntimeModuleId, matrixTypeEnvKeys, matrixTypeEnvProperties, matrixTypeOutput, renderMatrixTypes } from './generate.js' export interface TypeHost { name: 'vite' | 'electron-vite' @@ -21,6 +22,9 @@ const typeHostRules: Array<{ name: TypeHost['name'], pattern: RegExp }> = [ export interface TypePreparationRequest { host: TypeHost mode: string + command?: 'serve' | 'build' + nodeEnv?: string + automatic?: boolean } export type TypePreparationResult = { types: GenerateMatrixTypesOptions[] | null } | { error: string } @@ -71,7 +75,7 @@ export async function resolveHostTypes(cwd: string, env: EnvMap, request: TypePr return new Promise((resolve, reject) => { const child = fork(workerUrl, [], { cwd, - env: Object.fromEntries(Object.entries(env).map(([key, value]) => [key, String(value)])), + env: Object.fromEntries(Object.entries(env).filter(([key]) => key !== MATRIX_TYPE_CONTRACT_KEY).map(([key, value]) => [key, String(value)])), execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'], }) @@ -133,6 +137,7 @@ export function mergePreparedTypes(batches: GenerateMatrixTypesOptions[][]): Gen const merged = new Map() const signatures = new Map() const moduleOutputs = new Map() + const requiredKeys = new Map>() for (const batch of batches) { const seen = new Set() for (const options of batch) { @@ -154,12 +159,43 @@ export function mergePreparedTypes(batches: GenerateMatrixTypesOptions[][]): Gen if (signatures.has(output) && signatures.get(output) !== signature) throw new Error(`Conflicting Matrix type contracts at ${output}; keep scope, prefixes, and output stable across products.`) signatures.set(output, signature) + const keys = new Set(Object.keys(options.env).filter(key => !options.optionalEnvKeys?.includes(key))) + const required = requiredKeys.get(output) + requiredKeys.set(output, required ? new Set([...required].filter(key => keys.has(key))) : keys) const previous = merged.get(output) merged.set(output, { ...options, env: matrixTypeEnvKeys(previous?.env, options.env) }) } } - const options = [...merged.values()] - for (const declaration of options) + const options: GenerateMatrixTypesOptions[] = [...merged.entries()].map(([output, declaration]) => ({ + ...declaration, + optionalEnvKeys: Object.keys(declaration.env).filter(key => !requiredKeys.get(output)!.has(key)).sort(), + })) + // ImportMetaEnv is global even when virtual MatrixConfig modules are scoped. + // Agree on each raw key's presence across all collected scopes, without + // widening any scope's own config fields or their parsed schema types. + const projectRoots = [...new Set(batches.flat().map(declaration => resolveFilesystemPath(declaration.typeProjectRoot ?? declaration.cwd)))].sort((a, b) => a.length - b.length) + // Collection expands overlapping project aliases; their declarations may + // coexist too. Use the outermost collected owner, not the nested host cwd. + const globalRoot = (declaration: GenerateMatrixTypesOptions): string => { + const root = resolveFilesystemPath(declaration.typeProjectRoot ?? declaration.cwd) + return projectRoots.find(candidate => containsPath(candidate, root))! + } + const globals = new Map, required: Set }>() + for (const declaration of options) { + const project = globalRoot(declaration) + const properties = matrixTypeEnvProperties(declaration) + const keys = new Set(properties.map(property => property.key)) + const required = new Set(properties.filter(property => !property.optional).map(property => property.key)) + const previous = globals.get(project) + globals.set(project, { + keys: new Set([...previous?.keys ?? [], ...keys]), + required: previous ? new Set([...previous.required].filter(key => required.has(key))) : required, + }) + } + for (const declaration of options) { + const global = globals.get(globalRoot(declaration))! + declaration.optionalImportMetaEnvKeys = [...global.keys].filter(key => !global.required.has(key)).sort() renderMatrixTypes(declaration) + } return options } diff --git a/src/typegen/project.ts b/src/typegen/project.ts index 99c7d14..cb953d7 100644 --- a/src/typegen/project.ts +++ b/src/typegen/project.ts @@ -1,11 +1,11 @@ import type { loadMatrixConfig } from '../config/index.js' -import type { EnvMap } from '../types.js' import type { GenerateMatrixTypesOptions } from './generate.js' import { MATRIX_DEFAULTS } from '../config/defaults.js' -import { MATRIX_ENV_SCHEMA_KEY, resolveSchemaEnv, serializeEnvSchema } from '../config/env-schema.js' +import { MATRIX_ENV_SCHEMA_KEY, readEnvSchema, resolveSchemaEnv, serializeEnvSchema } from '../config/env-schema.js' import { resolveProductContext } from '../product/context.js' -import { resolveExistingPath, resolveFilesystemPath } from '../utils/path.js' +import { containsPath, resolveExistingPath, resolveFilesystemPath, resolveFuturePath } from '../utils/path.js' import { generateMatrixTypes, matrixTypeEnvKeys, matrixTypeOutput } from './generate.js' +import { withTypePreparationLock } from './lock.js' import { findTypeHost, mergePreparedTypes, resolveHostTypes, TypePreparationCancelled } from './prepare.js' type Products = Awaited>['products'] @@ -18,7 +18,21 @@ export class ProjectTypePreparationError extends Error { } } -export async function generateProjectTypes(loaded: LoadedConfig, products: Product[], options: { onOutput?: (output: string) => void, signal?: AbortSignal } = {}): Promise { +interface ProjectTypeOptions { + automatic?: boolean + onHost?: () => void + onOutput?: (output: string) => void + onDeclaration?: (declaration: GenerateMatrixTypesOptions) => void + signal?: AbortSignal + /** Execution tasks determine affected projects, including dependency projects. */ + projects?: string[] +} + +export async function generateProjectTypes(loaded: LoadedConfig, products: Product[], options: ProjectTypeOptions = {}): Promise { + return withTypePreparationLock(loaded.cwd, () => collectProjectTypes(loaded, products, options), options.signal) +} + +async function collectProjectTypes(loaded: LoadedConfig, products: Product[], options: ProjectTypeOptions): Promise { const { onOutput, signal } = options const preparationError = (error: unknown, project: string, task?: string): Error => { if (error instanceof TypePreparationCancelled) @@ -32,23 +46,58 @@ export async function generateProjectTypes(loaded: LoadedConfig, products: Produ throw preparationError(signal.reason, 'types') } checkCancellation() - const projectProducts = new Map() - for (const product of products) { - for (const variant of Object.values(product.variants)) { - if (!loaded.projects[variant.project]) - throw new Error(`Variant ${product.key}/${variant.id} references unknown project ${variant.project}`) - const linkedProducts = projectProducts.get(variant.project) ?? [] - if (!linkedProducts.includes(product)) - linkedProducts.push(product) - projectProducts.set(variant.project, linkedProducts) + const affectedProjects = new Set(options.projects ?? products.flatMap(product => Object.values(product.variants).map(variant => variant.project))) + // Automatic host discovery belongs to the selected task, not an overlapping neighbor. + // Non-Vite adapters retain their own generation even when a nested project uses Vite. + if (options.automatic) { + for (const name of affectedProjects) { + const project = loaded.projects[name] + if (!project) + throw new Error(`Unknown project ${name}`) + if (!await findTypeHost(resolveFilesystemPath(loaded.cwd, project.root ?? MATRIX_DEFAULTS.projectRoot), project.configFile, signal)) + affectedProjects.delete(name) } + if (!affectedProjects.size) + return [] + } + // Overlapping physical roots can own the same output, including nested project aliases. + const roots = new Map() + for (const [name, project] of Object.entries(loaded.projects)) + roots.set(name, await resolveFuturePath(resolveFilesystemPath(loaded.cwd, project.root ?? MATRIX_DEFAULTS.projectRoot))) + let expanded = true + while (expanded) { + expanded = false + for (const [name, root] of roots) { + if (affectedProjects.has(name)) + continue + if ([...affectedProjects].some(selected => roots.has(selected) && (containsPath(roots.get(selected)!, root) || containsPath(root, roots.get(selected)!)))) { + affectedProjects.add(name) + expanded = true + } + } + } + const projectProducts = new Map() + for (const projectName of affectedProjects) { + if (!loaded.projects[projectName]) + throw new Error(`Unknown project ${projectName}`) + projectProducts.set(projectName, Object.values(loaded.products).filter(product => Object.values(product.variants).some(variant => variant.project === projectName))) } + const serializedSchema = serializeEnvSchema(loaded.config.envSchema) + const typeSchema = readEnvSchema({ [MATRIX_ENV_SCHEMA_KEY]: serializedSchema }) const batches: GenerateMatrixTypesOptions[][] = [] + const outputsToWrite = new Set() const outputSources = new Map, tasks: Set }>() - const addBatch = (batch: GenerateMatrixTypesOptions[], project: string, tasks: string[]): void => { + const addBatch = async (batch: GenerateMatrixTypesOptions[], project: string, tasks: string[], publish = true): Promise => { for (const declaration of batch) { - const output = matrixTypeOutput(declaration) + const output = await resolveFuturePath(matrixTypeOutput(declaration)) + const root = roots.get(project)! + if (!containsPath(root, declaration.cwd) || !containsPath(root, output)) + throw new Error(`Cannot establish Matrix type output ownership for project ${project}: host root ${declaration.cwd} and output ${output} must stay within project.root ${root}. Set project.root to the actual shared host project directory; use project.configFile to reference a configuration stored elsewhere.`) + declaration.typeProjectRoot = root + declaration.output = output + if (publish) + outputsToWrite.add(output) const source = outputSources.get(output) ?? { projects: new Set(), tasks: new Set() } source.projects.add(project) for (const task of tasks) @@ -62,56 +111,66 @@ export async function generateProjectTypes(loaded: LoadedConfig, products: Produ let task: string | undefined try { const project = loaded.projects[projectName]! - let cwd = resolveFilesystemPath(loaded.cwd, project.root ?? MATRIX_DEFAULTS.projectRoot) - const host = await findTypeHost(cwd, project.configFile, signal) + let cwd = roots.get(projectName)! + // Resolve relative config paths before canonicalizing the worker cwd. + const host = await findTypeHost(resolveFilesystemPath(loaded.cwd, project.root ?? MATRIX_DEFAULTS.projectRoot), project.configFile, signal) if (host) { + options.onHost?.() cwd = await resolveExistingPath(cwd) checkCancellation() host.configFile = await resolveExistingPath(host.configFile) } checkCancellation() - const envs: Array = [loaded.config.env, loaded.externalEnv, ...linkedProducts.map(product => product.env)] - const genericTypes: GenerateMatrixTypesOptions[] = [{ - cwd, - env: matrixTypeEnvKeys(...envs), - ...(loaded.config.envSchema ? { envSchema: loaded.config.envSchema } : {}), - }] - if (host) { - let expectedScopes: string | undefined - for (const product of linkedProducts) { - for (const variant of Object.values(product.variants).filter(variant => variant.project === projectName)) { - task = `${product.key}:${variant.id}:prepare` - const effectiveEnv = resolveSchemaEnv({ - ...loaded.config.env, - ...product.env, - ...loaded.externalEnv, - }, loaded.config.envSchema) - const identity = resolveProductContext({ config: loaded.config, product, variant, envName: loaded.envName, env: effectiveEnv, projectRoot: cwd }) + let expectedScopes: string | undefined + for (const product of linkedProducts) { + for (const variant of Object.values(product.variants).filter(variant => variant.project === projectName)) { + task = `${product.key}:${variant.id}:prepare` + const effectiveEnv = resolveSchemaEnv({ + ...loaded.config.env, + ...product.env, + ...loaded.externalEnv, + }, loaded.config.envSchema) + const genericTypes: GenerateMatrixTypesOptions[] = [{ + cwd, + env: matrixTypeEnvKeys(effectiveEnv), + envSchema: typeSchema, + }] + if (!host) { + // Generic owners participate in raw-key reconciliation, but automatic + // Vite collection must not create or overwrite their declarations. + await addBatch(genericTypes, projectName, [task], !options.automatic) + continue + } + const identity = resolveProductContext({ config: loaded.config, product, variant, envName: loaded.envName, env: effectiveEnv, projectRoot: cwd }) + // Resolve both real host lifecycles even if this invocation selects only one. + // Never assume a development/serve resolution covers production/build. + for (const target of ['dev', 'build'] as const) { + const configured = variant.targets[target] + const context = { + command: target === 'dev' ? 'serve' as const : 'build' as const, + nodeEnv: configured ? configured.nodeEnv : target === 'dev' ? 'development' : 'production', + } const env = { ...effectiveEnv, ...identity.env, - NODE_ENV: 'development', - MATRIX_NODE_ENV: 'development', + NODE_ENV: context.nodeEnv, + MATRIX_NODE_ENV: context.nodeEnv, MATRIX_ENV_NAME: loaded.envName, MATRIX_PROJECT: projectName, MATRIX_VARIANT: variant.id, - MATRIX_TARGET: 'prepare', - [MATRIX_ENV_SCHEMA_KEY]: serializeEnvSchema(loaded.config.envSchema), + MATRIX_TARGET: target, + [MATRIX_ENV_SCHEMA_KEY]: serializedSchema, } - const preparedTypes = await resolveHostTypes(cwd, env, { host, mode: loaded.envName }, signal) + const preparedTypes = await resolveHostTypes(cwd, env, { host, mode: loaded.envName, ...context, automatic: options.automatic ?? false }, signal) checkCancellation() const scopes = JSON.stringify(preparedTypes?.map(type => [type.cwd, type.scope ?? '']).sort() ?? null) if (expectedScopes !== undefined && scopes !== expectedScopes) - throw new Error(`Matrix scopes change between products or variants in ${cwd}; keep the type contract stable.`) + throw new Error(`Matrix scopes change between products, variants, or host contexts in ${cwd}; keep the type contract stable.`) expectedScopes = scopes - addBatch(preparedTypes ?? genericTypes, projectName, [task]) + await addBatch(preparedTypes ?? genericTypes, projectName, [task], !options.automatic || preparedTypes !== null) } } - continue } - addBatch(genericTypes, projectName, linkedProducts.flatMap(product => Object.values(product.variants) - .filter(variant => variant.project === projectName) - .map(variant => `${product.key}:${variant.id}:prepare`))) } catch (error) { throw preparationError(error, projectName, task) @@ -119,12 +178,15 @@ export async function generateProjectTypes(loaded: LoadedConfig, products: Produ } const outputs: string[] = [] checkCancellation() - for (const options of mergePreparedTypes(batches)) { + for (const declaration of mergePreparedTypes(batches)) { checkCancellation() - const source = outputSources.get(matrixTypeOutput(options))! + if (!outputsToWrite.has(matrixTypeOutput(declaration))) + continue + const source = outputSources.get(matrixTypeOutput(declaration))! try { - const output = await generateMatrixTypes(options, signal) + const output = await generateMatrixTypes(declaration, signal) outputs.push(output) + options.onDeclaration?.(declaration) onOutput?.(output) } catch (error) { diff --git a/src/typegen/worker.ts b/src/typegen/worker.ts index 63c5367..31f28df 100644 --- a/src/typegen/worker.ts +++ b/src/typegen/worker.ts @@ -22,7 +22,7 @@ process.once('message', async (request: TypePreparationRequest) => { plugins++ if (options) types.push(options) - }) + }, request.automatic) const root = await resolveExistingPath(process.cwd()) const require = createRequire(path.join(root, 'package.json')) const hostEntry = require.resolve(request.host.name) @@ -33,20 +33,22 @@ process.once('message', async (request: TypePreparationRequest) => { if (typeof resolveConfig !== 'function') throw new Error(`Vite at ${viteEntry} does not expose resolveConfig`) const inline = { configFile: request.host.configFile, mode: request.mode, logLevel: 'silent' as const } + const command = request.command ?? 'serve' + const nodeEnv = request.nodeEnv ?? 'development' if (request.host.name === 'vite') { - await resolveConfig(inline, 'serve', 'development', 'development') + await resolveConfig(inline, command, nodeEnv, nodeEnv) } else { const electron = await import(pathToFileURL(hostEntry).href) as { - resolveConfig: (config: InlineConfig, command: 'serve', mode: string) => Promise<{ config?: { main?: InlineConfig, preload?: InlineConfig, renderer?: InlineConfig } }> + resolveConfig: (config: InlineConfig, command: 'serve' | 'build', mode: string) => Promise<{ config?: { main?: InlineConfig, preload?: InlineConfig, renderer?: InlineConfig } }> } - process.env.NODE_ENV_ELECTRON_VITE = 'development' - const resolved = await electron.resolveConfig({ ...inline, root }, 'serve', 'development') + process.env.NODE_ENV_ELECTRON_VITE = nodeEnv + const resolved = await electron.resolveConfig({ ...inline, root }, command, nodeEnv) // Electron's dev lifecycle builds main/preload but serves renderer. Only resolve those configs here. for (const context of ['main', 'preload', 'renderer'] as const) { const config = resolved.config?.[context] if (config) - await resolveConfig(config, context === 'renderer' ? 'serve' : 'build', 'development', 'development') + await resolveConfig(config, context === 'renderer' ? command : 'build', nodeEnv, nodeEnv) } } // Send field names and schema only, never configuration values. diff --git a/src/unplugin/index.ts b/src/unplugin/index.ts index 33f0085..b5958de 100644 --- a/src/unplugin/index.ts +++ b/src/unplugin/index.ts @@ -11,9 +11,11 @@ import { MATRIX_ENV_SCHEMA_KEY, readEnvSchema } from '../config/env-schema.js' import { ensureMatrixEnvPrefix } from '../runtime/public-env.js' import { createMatrixRuntime, runtimeModule } from '../runtime/snapshot.js' import { inlineMatrixReads } from '../runtime/transform.js' -import { collectPreparedTypes } from '../typegen/collector.js' -import { generateMatrixTypes, matrixRuntimeModuleId } from '../typegen/generate.js' +import { collectPreparedTypes, isAutomaticTypeCollection } from '../typegen/collector.js' +import { hasPreparedTypeContract, MATRIX_TYPE_CONTRACT_KEY } from '../typegen/coordination.js' +import { generateMatrixTypes, matrixRuntimeModuleId, matrixTypeOutput } from '../typegen/generate.js' import { currentProcessEnv } from '../utils/env.js' +import { resolveExistingPath, resolveFuturePath } from '../utils/path.js' import { setupMatrixEsbuildTransform } from './esbuild-transform.js' import { isVueScriptRequest } from './vue.js' @@ -36,6 +38,8 @@ function isScriptFile(id: string): boolean { export const matrixUnpluginFactory: UnpluginFactory = (options = {}, meta) => { let envPrefix = ensureMatrixEnvPrefix(options.envPrefix) let env = currentProcessEnv() + // Collection resolves Vite configs only; other adapters retain host-local generation. + const preparedContract = meta.framework === 'vite' ? env[MATRIX_TYPE_CONTRACT_KEY] : undefined const hasMatrixContext = Object.hasOwn(env, MATRIX_ENV_SCHEMA_KEY) const envSchema = readEnvSchema(env) let typesGenerated = false @@ -71,7 +75,11 @@ export const matrixUnpluginFactory: UnpluginFactory plugin.name === 'vite:vue' && typeof plugin.api?.version === 'string') // Even a custom broad prefix must not make internal metadata public. delete config.env[MATRIX_ENV_SCHEMA_KEY] + delete config.env[MATRIX_TYPE_CONTRACT_KEY] envPrefix = ensureMatrixEnvPrefix(config.envPrefix) env = { ...config.env } // Preparation needs the schema, not credentials or values for a runnable build. - if (collectPreparedTypes(options.types === false ? undefined : typeOptions(config.root))) + if (collectPreparedTypes(options.types === false || (options.types === undefined && isVitest() && isAutomaticTypeCollection()) ? undefined : typeOptions(config.root))) return runtime = createMatrixRuntime(env, envPrefix, envSchema) await generateTypes(config.root) diff --git a/src/utils/path.ts b/src/utils/path.ts index ee94319..99ee375 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,4 +1,4 @@ -import { realpath } from 'node:fs/promises' +import { lstat, realpath } from 'node:fs/promises' import path from 'node:path' import { normalize } from 'pathe' @@ -11,3 +11,37 @@ export function resolveFilesystemPath(...segments: string[]): string { export async function resolveExistingPath(path: string): Promise { return normalize(await realpath(path)) } + +/** Canonical identity for an output that may not exist yet, including symlinked parents. */ +export async function resolveFuturePath(filename: string): Promise { + let current = resolveFilesystemPath(filename) + const suffix: string[] = [] + while (true) { + try { + return normalize(path.join(await realpath(current), ...suffix.reverse())) + } + catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT' && code !== 'ENOTDIR') + throw error + const info = await lstat(current).catch((failure: NodeJS.ErrnoException) => { + if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') + return undefined + throw failure + }) + // A dangling link has no verifiable physical owner; do not treat it as a new path. + if (info?.isSymbolicLink()) + throw new Error(`Cannot resolve Matrix output ownership through dangling symlink ${current}`, { cause: error }) + const parent = path.dirname(current) + if (parent === current) + throw error + suffix.push(path.basename(current)) + current = parent + } + } +} + +export function containsPath(root: string, filename: string): boolean { + const relative = path.relative(root, filename) + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} diff --git a/test/README.md b/test/README.md index 385d073..3ef8359 100644 --- a/test/README.md +++ b/test/README.md @@ -167,9 +167,14 @@ Explicit override cases cover both option forms with and without Vitest, plus one runtime-only case per host instead of a redundant full cross-product. The fixture clears inherited public test fields to keep empty-schema checks independent of the caller's environment. -`typegen/prepare-types.test.ts` retains the multi-product merge and -scope-preparation contracts; package consumers additionally verify installed -adapters and all three Electron type-preparation combinations. +`typegen/prepare-types.test.ts` covers multi-product merge and scope preparation. +`typegen/shared-contract.test.ts` covers shared-contract lifecycle, dev/build +contexts, contention, and active-product runtime isolation, including a real Vite +dev server (POSIX only) and electron-vite scope builds. Electron bundles are +checked without launching Electron; these tests do not replace app acceptance. +`typegen/coordination.test.ts` covers lock waiting, cancellation, and owner recovery; +`execution/prepare.test.ts` covers cancellation of in-flight preparation on service +failure. Package consumers verify installed adapters and Electron preparation. ## Runtime conformance tests diff --git a/test/cli/cli.test.ts b/test/cli/cli.test.ts index f140e2e..69087ab 100644 --- a/test/cli/cli.test.ts +++ b/test/cli/cli.test.ts @@ -86,16 +86,16 @@ describe('cli entry', () => { const webTypes = await readFile(path.join(cwd, 'apps/web/.matrix/types/matrix-runtime.d.ts'), 'utf8') const desktopTypes = await readFile(path.join(cwd, 'apps/desktop/.matrix/types/matrix-runtime.d.ts'), 'utf8') - expect(webTypes).toContain('readonly VITE_WEB_ONLY: string') + expect(webTypes).toContain('readonly VITE_WEB_ONLY?: string') expect(webTypes).toContain('readonly VITE_SHARED: string') - expect(webTypes).toContain('readonly VITE_SECOND_WEB: string') + expect(webTypes).toContain('readonly VITE_SECOND_WEB?: string') expect(webTypes).toContain('readonly enabled: boolean') expect(webTypes).toContain('readonly mode: "remote" | "bundled"') expect(webTypes).not.toContain('MAIN_VITE_SECRET') - expect(webTypes).not.toContain('readonly VITE_DESKTOP_ONLY: string') + expect(webTypes).not.toMatch(/readonly VITE_DESKTOP_ONLY[?:]/) expect(desktopTypes).toContain('readonly VITE_DESKTOP_ONLY: string') expect(desktopTypes).toContain('readonly VITE_SHARED: string') - expect(desktopTypes).not.toContain('readonly VITE_WEB_ONLY: string') + expect(desktopTypes).not.toMatch(/readonly VITE_WEB_ONLY[?:]/) expect(await readFile(path.join(cwd, 'apps/web/prepared'), 'utf8')).toBe('done;') expect(await readFile(path.join(cwd, 'apps/desktop/prepared'), 'utf8')).toBe('done;') const summaries = messages.filter(message => message.startsWith('Execution ')) diff --git a/test/execution/prepare.test.ts b/test/execution/prepare.test.ts index 45ad7c2..cf8a88f 100644 --- a/test/execution/prepare.test.ts +++ b/test/execution/prepare.test.ts @@ -63,6 +63,58 @@ function preparedProject(prepare: string | string[]): MatrixConfig { } describe('project preparation', () => { + it('aborts and drains in-flight type preparation when a service fails before launching its dependent', async () => { + await writeFile(path.join(cwd, 'service.mjs'), [ + 'import { existsSync } from "node:fs";', + 'setInterval(() => { if (existsSync("fail-now")) process.exit(7) }, 10);', + ].join('\n')) + const plan = await execution({ + projects: { + service: { targets: { test: { command: 'node service.mjs', continuous: true } } }, + dependent: { targets: { test: { command: 'node record.mjs dependent', dependsOn: [{ variant: 'service', condition: 'ready' }] } } }, + }, + products: { app: { variants: { dependent: 'dependent', service: 'service' } } }, + }, ['app']) + let observedSignal: AbortSignal | undefined + let release!: () => void + const cleanupGate = new Promise((resolve) => { + release = resolve + }) + let settled = false + let cleanupFinished = false + const running = runExecutionPlan(plan, { + beforeTask: async (task, signal) => { + if (task.project !== 'dependent') + return + observedSignal = signal + await writeFile(path.join(cwd, 'fail-now'), '') + // Model asynchronous worker cleanup which must finish before execution rejects. + await cleanupGate + cleanupFinished = true + signal.throwIfAborted() + }, + }) + const outcome = running.then(value => ({ value }), error => ({ error })).finally(() => { + settled = true + }) + try { + await vi.waitFor(() => expect(observedSignal?.aborted).toBe(true)) + expect(settled).toBe(false) + expect(cleanupFinished).toBe(false) + release() + const result = await outcome + expect(result).toHaveProperty('error') + expect('error' in result && result.error.message).toContain('app:service:test exited with code 7') + expect(cleanupFinished).toBe(true) + await expect(readFile(path.join(cwd, 'events.jsonl'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(messages.filter(message => message.startsWith('Execution '))).toHaveLength(1) + } + finally { + release() + await outcome + } + }) + it('prepares dependencies and shared projects once per invocation, after skipped targets', async () => { await writeFile(path.join(cwd, '.env.qa'), 'PREP_DOTENV=dotenv-value') const plan = await execution({ diff --git a/test/typegen/coordination.test.ts b/test/typegen/coordination.test.ts new file mode 100644 index 0000000..b6e5377 --- /dev/null +++ b/test/typegen/coordination.test.ts @@ -0,0 +1,396 @@ +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import { setImmediate } from 'node:timers/promises' +import path from 'pathe' +import { describe, expect, it, vi } from 'vitest' +import { hasPreparedTypeContract } from '../../src/typegen/coordination.js' +import { withTypePreparationLock } from '../../src/typegen/lock.js' +import { TypePreparationCancelled } from '../../src/typegen/prepare.js' +import { resolveExistingPath } from '../../src/utils/path.js' + +const waiting = vi.hoisted(() => ({ notify: () => {} })) + +const filesystem = vi.hoisted(() => ({ + beforeClaim: async (_file: unknown) => {}, + afterClaim: async (_file: unknown, _error?: unknown) => {}, + afterUnlink: async (_file: unknown) => {}, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const fs = await importOriginal() + return { + ...fs, + mkdir: async (...args: Parameters) => { + const [file] = args + await filesystem.beforeClaim(file) + let failure: unknown + let result: Awaited> + try { + result = await fs.mkdir(...args) + } + catch (error) { + failure = error + } + await filesystem.afterClaim(file, failure) + if (failure) + throw failure + return result + }, + unlink: async (file: Parameters[0]) => { + await fs.unlink(file) + await filesystem.afterUnlink(file) + }, + } +}) + +vi.mock('node:timers/promises', async (importOriginal) => { + const timers = await importOriginal() + return { + ...timers, + setTimeout: (...args: Parameters) => { + const pending = timers.setTimeout(...args) + waiting.notify() + return pending + }, + } +}) + +function nextLockWait(): Promise { + return new Promise((resolve) => { + waiting.notify = resolve + }) +} + +describe('type preparation coordination', () => { + it.each([null, {}, { strict: true, declarations: [null] }, { strict: true, declarations: [{ output: '/file', signature: '', keys: [42] }] }])('rejects malformed internal contracts clearly: %j', (contract) => { + expect(() => hasPreparedTypeContract(JSON.stringify(contract), { cwd: '/project', env: {} })).toThrow('Invalid Matrix internal type contract') + }) + + it('serializes collection and preserves cancellation while waiting without stealing the owner lock', async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'matrix-lock-')) + let release!: () => void + let started!: () => void + const entered = new Promise((resolve) => { + started = resolve + }) + const gate = new Promise((resolve) => { + release = resolve + }) + const first = withTypePreparationLock(cwd, async () => { + started() + await gate + return 'first' + }) + try { + await entered + const controller = new AbortController() + const reason = new TypePreparationCancelled('cancelled') + let secondEntered = false + const secondWaiting = nextLockWait() + const second = withTypePreparationLock(cwd, async () => { + secondEntered = true + }, controller.signal) + // This notification is emitted only after mkdir rejected with EEXIST + // and the abortable retry timer was installed. + await secondWaiting + controller.abort(reason) + await expect(second).rejects.toBe(reason) + expect(secondEntered).toBe(false) + let thirdEntered = false + const thirdWaiting = nextLockWait() + const third = withTypePreparationLock(cwd, async () => { + thirdEntered = true + return 'third' + }) + await thirdWaiting + expect(thirdEntered).toBe(false) + release() + await expect(first).resolves.toBe('first') + await expect(third).resolves.toBe('third') + } + finally { + release() + await first + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('elects exactly one remover when three recoverers read the same dead owner', async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'matrix-recovery-race-')) + const identity = await resolveExistingPath(cwd) + const lock = path.join(os.tmpdir(), `matrix-types-${createHash('sha256').update(identity).digest('hex')}.lock`) + const owner = `2147483647-${randomUUID()}.owner` + const claim = `${lock}.${owner}.releasing` + await mkdir(lock) + await writeFile(path.join(lock, owner), '') + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 2147483647) + throw Object.assign(new Error('dead owner'), { code: 'ESRCH' }) + return true + }) + let releaseAttempts!: () => void + const attempts = new Promise((resolve) => { + releaseAttempts = resolve + }) + let allAttempting!: () => void + const attempting = new Promise((resolve) => { + allAttempting = resolve + }) + let releaseRemoval!: () => void + const removal = new Promise((resolve) => { + releaseRemoval = resolve + }) + const attemptedOwners: string[] = [] + filesystem.beforeClaim = async (file) => { + if (file === claim) { + // Hold later retries outside this three-contender election so scheduler + // delays cannot turn two losing contenders into extra observations. + if (attemptedOwners.length >= 3) { + await removal + return + } + attemptedOwners.push(file) + if (attemptedOwners.length === 3) + allAttempting() + await attempts + } + } + let allAttempted!: () => void + const attempted = new Promise((resolve) => { + allAttempted = resolve + }) + const outcomes: Array = [] + filesystem.afterClaim = async (file, error) => { + if (file === claim) { + outcomes.push((error as NodeJS.ErrnoException | undefined)?.code) + if (outcomes.length === 3) + allAttempted() + } + } + let ownerRemoved!: () => void + const removed = new Promise((resolve) => { + ownerRemoved = resolve + }) + let removals = 0 + filesystem.afterUnlink = async (file) => { + if (typeof file === 'string' && path.basename(file) === owner) { + removals++ + ownerRemoved() + await removal + } + } + const controller = new AbortController() + let phase = 'waiting for three verified recovery claims' + const watchdog = setTimeout(() => { + controller.abort(new Error(`Recovery test stalled ${phase}; attempts=${attemptedOwners.length}, outcomes=${JSON.stringify(outcomes)}`)) + }, 5_000) + const aborted = new Promise((_resolve, reject) => { + controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true }) + }) + let entered = 0 + let active = 0 + let maximum = 0 + const successors = [1, 2, 3].map(value => withTypePreparationLock(cwd, async () => { + entered++ + maximum = Math.max(maximum, ++active) + await setImmediate() + active-- + return value + }, controller.signal)) + const completed = Promise.all(successors) + const prematureCompletion = completed.then(() => { + throw new Error(`Successors completed prematurely ${phase}`) + }) + try { + await Promise.race([attempting, prematureCompletion, aborted]) + // All contenders verified the same dead owner before trying the claim. + // Election must finish before any contender can unlink the owner marker. + expect(attemptedOwners).toEqual(Array.from({ length: 3 }).fill(claim)) + expect(kill.mock.calls.filter(([pid]) => pid === 2147483647)).toHaveLength(3) + phase = 'waiting for one claim winner and two existing-claim results' + releaseAttempts() + await Promise.race([attempted, prematureCompletion, aborted]) + expect(outcomes.filter(code => code === undefined)).toHaveLength(1) + expect(outcomes.filter(code => code === 'EEXIST')).toHaveLength(2) + phase = 'waiting for the claim holder to remove the marker' + await Promise.race([removed, prematureCompletion, aborted]) + expect(removals).toBe(1) + expect(entered).toBe(0) + // Losing recoverers must leave the now-empty directory for the winner. + expect(await readdir(lock)).toEqual([]) + expect(await readdir(claim)).toEqual([]) + phase = 'waiting for serialized successors' + releaseRemoval() + expect(await Promise.race([completed, aborted])).toEqual([1, 2, 3]) + expect(maximum).toBe(1) + } + finally { + clearTimeout(watchdog) + // Always release mock gates before awaiting cleanup, including failures + // before all contenders arrive. Observe every successor rejection. + controller.abort() + releaseAttempts() + releaseRemoval() + await Promise.allSettled([...successors, prematureCompletion, aborted]) + kill.mockRestore() + filesystem.beforeClaim = async () => {} + filesystem.afterClaim = async () => {} + filesystem.afterUnlink = async () => {} + await rm(claim, { recursive: true, force: true }) + await rm(lock, { recursive: true, force: true }) + await rm(cwd, { recursive: true, force: true }) + } + }, 10_000) + + it('leaves a live successor untouched when a stale recovery claims late', async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'matrix-late-recovery-')) + const identity = await resolveExistingPath(cwd) + const lock = path.join(os.tmpdir(), `matrix-types-${createHash('sha256').update(identity).digest('hex')}.lock`) + const owner = `2147483647-${randomUUID()}.owner` + const claim = `${lock}.${owner}.releasing` + await mkdir(lock) + await writeFile(path.join(lock, owner), '') + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 2147483647) + throw Object.assign(new Error('dead owner'), { code: 'ESRCH' }) + return true + }) + let releaseClaim!: () => void + const claimGate = new Promise((resolve) => { + releaseClaim = resolve + }) + let attempts = 0 + filesystem.beforeClaim = async (file) => { + if (file === claim && ++attempts === 1) + await claimGate + } + let releaseAction!: () => void + const actionGate = new Promise((resolve) => { + releaseAction = resolve + }) + let entered = 0 + const controller = new AbortController() + const successors = [1, 2].map(value => withTypePreparationLock(cwd, async () => { + entered++ + await actionGate + return value + }, controller.signal)) + const settled = Promise.allSettled(successors) + try { + await expect.poll(() => entered).toBe(1) + const successorOwner = await readdir(lock) + expect(successorOwner).toHaveLength(1) + expect(successorOwner).not.toContain(owner) + // The first recoverer resumes only after another contender has retired + // the original lock, removed its claim, and acquired a new generation. + let retried = false + waiting.notify = () => { + retried = true + } + releaseClaim() + await expect.poll(() => retried).toBe(true) + expect(await readdir(lock)).toEqual(successorOwner) + expect(entered).toBe(1) + } + finally { + controller.abort() + releaseClaim() + releaseAction() + await settled + filesystem.beforeClaim = async () => {} + waiting.notify = () => {} + kill.mockRestore() + await rm(claim, { recursive: true, force: true }) + await rm(lock, { recursive: true, force: true }) + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('fails closed on an abandoned recovery claim and explains manual cleanup', async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'matrix-recovery-claim-')) + const identity = await resolveExistingPath(cwd) + const lock = path.join(os.tmpdir(), `matrix-types-${createHash('sha256').update(identity).digest('hex')}.lock`) + const owner = `2147483647-${randomUUID()}.owner` + const claim = `${lock}.${owner}.releasing` + await mkdir(lock) + await writeFile(path.join(lock, owner), '') + await mkdir(claim) + const kill = vi.spyOn(process, 'kill').mockImplementation((pid) => { + if (pid === 2147483647) + throw Object.assign(new Error('dead owner'), { code: 'ESRCH' }) + return true + }) + let time = 0 + const now = vi.spyOn(Date, 'now').mockImplementation(() => time += 300_001) + try { + await expect(withTypePreparationLock(cwd, async () => { + throw new Error('An abandoned claim must not be stolen') + })).rejects.toThrow(`sibling ${path.basename(lock)}.*.releasing claim directories`) + expect(await readdir(lock)).toEqual([owner]) + expect(await readdir(claim)).toEqual([]) + now.mockRestore() + // Simulate the documented manual cleanup after confirming no recovery + // is running, then ensure the still-present dead owner is recoverable. + await rm(claim, { recursive: true }) + await expect(withTypePreparationLock(cwd, async () => 'recovered')).resolves.toBe('recovered') + } + finally { + now.mockRestore() + kill.mockRestore() + await rm(claim, { recursive: true, force: true }) + await rm(lock, { recursive: true, force: true }) + await rm(cwd, { recursive: true, force: true }) + } + }) + + it('recovers a killed process owner and serializes competing successors', async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'matrix-dead-lock-')) + const lockModule = new URL('../../src/typegen/lock.ts', import.meta.url).href + const child = spawn(process.execPath, ['--experimental-transform-types', '--input-type=module', '-e', ` + import { registerHooks } from 'node:module'; + registerHooks({ resolve(specifier, context, next) { + try { return next(specifier, context); } + catch (error) { + if (error.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.endsWith('.js')) throw error; + return next(specifier.slice(0, -3) + '.ts', context); + } + } }); + const { withTypePreparationLock } = await import(${JSON.stringify(lockModule)}); + await withTypePreparationLock(${JSON.stringify(cwd)}, async () => { + process.stdout.write('locked'); + await new Promise(() => { setInterval(() => {}, 1000); }); + }); + `], { stdio: ['ignore', 'pipe', 'pipe'] }) + const exited = once(child, 'exit') + try { + await Promise.race([ + once(child.stdout!, 'data'), + exited.then(() => { throw new Error('Lock owner exited before acquiring its lock') }), + ]) + child.kill('SIGKILL') + await exited + let active = 0 + let maximum = 0 + const successors = [1, 2, 3].map(value => withTypePreparationLock(cwd, async () => { + active++ + maximum = Math.max(maximum, active) + await setImmediate() + active-- + return value + }, AbortSignal.timeout(5_000))) + expect(await Promise.all(successors)).toEqual([1, 2, 3]) + expect(maximum).toBe(1) + // Normal release still permits a later invocation to acquire the lock. + await expect(withTypePreparationLock(cwd, async () => 'released')).resolves.toBe('released') + } + finally { + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL') + await exited + await rm(cwd, { recursive: true, force: true }) + } + }, 10_000) +}) diff --git a/test/typegen/deferred-preparation.test.ts b/test/typegen/deferred-preparation.test.ts new file mode 100644 index 0000000..488c263 --- /dev/null +++ b/test/typegen/deferred-preparation.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import { fileURLToPath } from 'node:url' +import path from 'pathe' +import { afterEach, describe, expect, it } from 'vitest' +import { runCli } from '../../src/cli/index.js' + +const originalCwd = process.cwd() +const directories: string[] = [] + +afterEach(async () => { + process.chdir(originalCwd) + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }) +}) + +describe('deferred preparation contracts', () => { + it.each([ + { initialHost: false, sharedOwner: false }, + { initialHost: true, sharedOwner: false }, + { initialHost: true, sharedOwner: true }, + ])('refreshes contracts after preparation (initial host: $initialHost, shared owner: $sharedOwner)', async ({ initialHost, sharedOwner }) => { + const cwd = path.normalize(await realpath(await mkdtemp(path.join(os.tmpdir(), 'matrix-deferred-types-')))) + directories.push(cwd) + await symlink(fileURLToPath(new URL('../../node_modules', import.meta.url)), path.join(cwd, 'node_modules'), 'junction') + await writeFile(path.join(cwd, 'package.json'), '{"type":"module"}') + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + projects: { + web: { ...(!sharedOwner ? { prepare: 'node prepare.mjs' } : {}), targets: { build: 'node build.mjs' } }, + ...(sharedOwner ? { other: { prepare: 'node prepare.mjs', targets: { build: 'node build.mjs' } } } : {}), + }, + products: { + alpha: { + env: { VITE_ALPHA: 'alpha' }, + variants: { + early: { project: 'web', targets: { build: { command: sharedOwner ? 'node build.mjs' : 'node early.mjs', prepare: false } } }, + later: { project: sharedOwner ? 'other' : 'web', targets: { build: { dependsOn: ['early'] } } }, + last: { project: 'web', targets: { build: { dependsOn: ['later'] } } }, + }, + }, + beta: { env: { VITE_BETA: 'beta' }, variants: { web: 'web' } }, + }, + })}`) + const plugin = fileURLToPath(new URL('../../src/unplugin/vite.ts', import.meta.url)) + const config = ` + import { appendFileSync, existsSync } from 'node:fs'; + import matrix from ${JSON.stringify(plugin)}; + appendFileSync('evaluations.jsonl', JSON.stringify({ active: !!process.env.ACTIVE_BUILD, prepared: existsSync('prepared') }) + '\\n'); + export default { plugins: [matrix({ types: true })], build: { write: false, lib: { entry: 'entry.mjs', formats: ['es'] } } }; + ` + await writeFile(path.join(cwd, 'host-template.mjs'), config) + if (initialHost) + await writeFile(path.join(cwd, 'vite.config.mjs'), config) + await writeFile(path.join(cwd, 'prepare.mjs'), ` + import { copyFileSync, writeFileSync } from 'node:fs'; + copyFileSync('host-template.mjs', 'vite.config.mjs'); + writeFileSync('.env.production', 'VITE_AFTER=prepared\\n'); + writeFileSync('prepared', 'yes'); + `) + await writeFile(path.join(cwd, 'early.mjs'), '') + await writeFile(path.join(cwd, 'entry.mjs'), 'export const value = 1') + await writeFile(path.join(cwd, 'build.mjs'), `process.env.ACTIVE_BUILD = 'yes'; const { build } = await import('vite'); await build();`) + process.chdir(cwd) + await runCli(['build', 'alpha', '--variant', 'last']) + + const declaration = await readFile(path.join(cwd, '.matrix/types/matrix-runtime.d.ts'), 'utf8') + expect(declaration).toContain('readonly alpha?: string') + expect(declaration).toContain('readonly beta?: string') + expect(declaration).toContain('readonly after: string') + const evaluations = (await readFile(path.join(cwd, 'evaluations.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + // Four associated variants, two canonical contexts. One owner reuses its + // refreshed contract; interleaved web → other → web must refresh both caches. + expect(evaluations.filter(entry => !entry.active && entry.prepared)).toHaveLength(sharedOwner ? 16 : 8) + expect(evaluations.filter(entry => !entry.active && !entry.prepared)).toHaveLength(initialHost ? 8 : 0) + expect(evaluations.filter(entry => entry.active)).toHaveLength(sharedOwner ? 3 : 2) + }, 30_000) +}) diff --git a/test/typegen/ownership.test.ts b/test/typegen/ownership.test.ts new file mode 100644 index 0000000..b12b7c6 --- /dev/null +++ b/test/typegen/ownership.test.ts @@ -0,0 +1,269 @@ +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { build as bundle } from 'esbuild' +import path from 'pathe' +import ts from 'typescript' +import { afterEach, describe, expect, it } from 'vitest' +import { runCli } from '../../src/cli/index.js' + +const previousCwd = process.cwd() +const directories: string[] = [] +const plugin = fileURLToPath(new URL('../../src/unplugin/vite.ts', import.meta.url)) + +afterEach(async () => { + process.chdir(previousCwd) + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }) +}) + +async function workspace(): Promise { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'matrix-ownership-')) + directories.push(cwd) + await symlink(fileURLToPath(new URL('../../node_modules', import.meta.url)), path.join(cwd, 'node_modules'), 'junction') + await writeFile(path.join(cwd, 'package.json'), '{"type":"module"}') + await mkdir(path.join(cwd, 'app')) + process.chdir(cwd) + return cwd +} + +describe('prepared declaration ownership', { timeout: 20_000 }, () => { + it('resolves explicit configs from the configured symlink path before canonicalizing', async () => { + const cwd = await workspace() + await mkdir(path.join(cwd, 'links/configs'), { recursive: true }) + await mkdir(path.join(cwd, 'configs')) + await symlink(path.join(cwd, 'app'), path.join(cwd, 'links/app'), 'junction') + const configFile = path.join(cwd, 'links/configs/vite.config.mjs') + await writeFile(configFile, `import matrix from ${JSON.stringify(plugin)}; + export default { plugins: [matrix({ types: true, scope: 'selected' })], build: { write: false, lib: { entry: 'entry.mjs', formats: ['es'] } } }; + `) + await writeFile(path.join(cwd, 'configs/vite.config.mjs'), 'throw new Error("Selected the canonical parent instead of the configured parent")') + await writeFile(path.join(cwd, 'app/entry.mjs'), 'export const value = 1') + await writeFile(path.join(cwd, 'app/build.mjs'), `import { build } from 'vite'; await build({ configFile: ${JSON.stringify(configFile)} });`) + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + projects: { app: { root: 'links/app', configFile: '../configs/vite.config.mjs', targets: { build: 'node build.mjs' } } }, + products: { alpha: { env: { VITE_SELECTED: 'yes' }, variants: { app: 'app' } } }, + })}`) + await runCli(['prepare', 'alpha']) + const output = path.join(cwd, 'app/.matrix/types/matrix-runtime-selected.d.ts') + const prepared = await readFile(output, 'utf8') + expect(prepared).toContain('readonly selected: string') + await runCli(['build', 'alpha']) + expect(await readFile(output, 'utf8')).toBe(prepared) + }) + + it.each([false, true])('retains generic owner optionality without publishing its files (config without Matrix: %s)', async (emptyHostConfig) => { + const cwd = await workspace() + if (emptyHostConfig) + await writeFile(path.join(cwd, 'vite.config.mjs'), 'export default {}') + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + projects: { + plain: { targets: { build: 'node unused.mjs' } }, + web: { root: 'app', targets: { build: 'node build.mjs' } }, + }, + products: { + alpha: { env: { VITE_X: 'alpha' }, variants: { plain: 'plain' } }, + gamma: { variants: { plain: 'plain' } }, + beta: { env: { VITE_X: 'beta' }, variants: { web: 'web' } }, + }, + })}`) + await writeFile(path.join(cwd, 'app/vite.config.mjs'), `import matrix from ${JSON.stringify(plugin)}; + export default { plugins: [matrix({ types: true, scope: 'web' })], build: { write: false, lib: { entry: 'entry.mjs', formats: ['es'] } } }; + `) + await writeFile(path.join(cwd, 'app/entry.mjs'), 'export const value = 1') + await writeFile(path.join(cwd, 'app/build.mjs'), 'import { build } from "vite"; await build();') + await runCli(['prepare']) + const generic = path.join(cwd, '.matrix/types/matrix-runtime.d.ts') + const nested = path.join(cwd, 'app/.matrix/types/matrix-runtime-web.d.ts') + const original = await readFile(generic, 'utf8') + // A sentinel detects even a same-contract rewrite of the hostless output. + await writeFile(generic, `${original}\n// hostless owner sentinel\n`) + const untouched = await readFile(generic, 'utf8') + const prepared = await readFile(nested, 'utf8') + await runCli(['build', 'beta']) + expect(await readFile(generic, 'utf8')).toBe(untouched) + expect(await readFile(nested, 'utf8')).toBe(prepared) + expect(prepared).toContain('readonly VITE_X?: string') + expect(prepared).toContain('readonly x: string') + const program = ts.createProgram([generic, nested], { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + noEmit: true, + skipLibCheck: false, + types: [], + paths: { '@xova/matrix/runtime': [fileURLToPath(new URL('../../src/runtime/index.ts', import.meta.url))] }, + }) + expect(ts.getPreEmitDiagnostics(program).map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([]) + await rm(path.join(cwd, '.matrix'), { recursive: true }) + await runCli(['build', 'beta']) + await expect(access(generic)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('merges fallback and host schemas at a shared output without changing default or optional semantics', async () => { + const cwd = await workspace() + await mkdir(path.join(cwd, 'empty')) + await writeFile(path.join(cwd, 'empty/vite.config.mjs'), 'export default {}') + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + envSchema: { + VITE_REQUIRED: { type: 'string' }, + VITE_OPTIONAL: { type: 'string', optional: true }, + VITE_DEFAULTED: { type: 'string', optional: true, default: 'fallback-value' }, + }, + projects: { + plain: { root: 'app', configFile: '../empty/vite.config.mjs', targets: { build: 'node unused.mjs' } }, + web: { root: 'app', targets: { build: 'node build.mjs' } }, + }, + products: { + alpha: { env: { VITE_REQUIRED: 'alpha' }, variants: { plain: 'plain' } }, + beta: { env: { VITE_REQUIRED: 'beta' }, variants: { web: 'web' } }, + }, + })}`) + await writeFile(path.join(cwd, 'app/vite.config.mjs'), `import matrix from ${JSON.stringify(plugin)}; + export default { plugins: [matrix({ types: true })], build: { lib: { entry: 'entry.mjs', formats: ['es'], fileName: () => 'built.mjs' } } }; + `) + await writeFile(path.join(cwd, 'app/entry.mjs'), 'import { matrix } from "virtual:matrix/runtime"; export const config = matrix.config;') + await writeFile(path.join(cwd, 'app/build.mjs'), ` + import { build } from 'vite'; + await build(); + const { config } = await import('./dist/built.mjs'); + if (config.defaulted !== 'fallback-value' || config.required !== 'beta' || config.optional !== undefined) + throw new Error('Schema defaults or optional values changed'); + `) + await runCli(['prepare', 'beta']) + const output = path.join(cwd, 'app/.matrix/types/matrix-runtime.d.ts') + const prepared = await readFile(output, 'utf8') + for (const name of ['REQUIRED', 'DEFAULTED']) { + expect(prepared).toContain(`readonly VITE_${name}: string`) + expect(prepared).toContain(`readonly ${name.toLowerCase()}: string`) + } + expect(prepared).toContain('readonly VITE_OPTIONAL?: string') + expect(prepared).toContain('readonly optional?: string') + expect(prepared).not.toContain('fallback-value') + await runCli(['build', 'beta']) + expect(await readFile(output, 'utf8')).toBe(prepared) + }) + + it('keeps a non-Vite ancestor build independent from its nested Vite project', async () => { + const cwd = await workspace() + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default { + projects: { + legacy: { targets: { build: 'node build.mjs' } }, + web: { root: 'app', targets: { build: 'node unused.mjs' } }, + }, + products: { + alpha: { env: { VITE_ALPHA: 'alpha' }, variants: { legacy: 'legacy' } }, + beta: { env: { VITE_BETA: 'beta' }, variants: { web: 'web' } }, + }, + }`) + await bundle({ + entryPoints: [fileURLToPath(new URL('../../src/unplugin/rollup.ts', import.meta.url))], + outfile: path.join(cwd, 'matrix-rollup.mjs'), + bundle: true, + packages: 'external', + platform: 'node', + format: 'esm', + }) + await writeFile(path.join(cwd, 'entry.mjs'), 'import { matrix } from "virtual:matrix/runtime"; export const config = matrix.config;') + await writeFile(path.join(cwd, 'build.mjs'), ` + import { rollup } from 'rollup'; + import matrix from './matrix-rollup.mjs'; + const result = await rollup({ input: 'entry.mjs', plugins: [matrix({ types: true })] }); + await result.write({ file: 'built.mjs', format: 'es' }); + await result.close(); + `) + await writeFile(path.join(cwd, 'app/vite.config.mjs'), ` + import { writeFileSync } from 'node:fs'; + import matrix from ${JSON.stringify(plugin)}; + writeFileSync('neighbor-evaluated', 'yes'); + export default { plugins: [matrix({ types: true })] }; + `) + await runCli(['build', 'alpha']) + const standalone = await readFile(path.join(cwd, '.matrix/types/matrix-runtime.d.ts'), 'utf8') + expect(standalone).toContain('readonly alpha: string') + expect(standalone).not.toContain('readonly beta') + expect(await readFile(path.join(cwd, 'built.mjs'), 'utf8')).toContain('alpha') + await expect(access(path.join(cwd, 'app/neighbor-evaluated'))).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(access(path.join(cwd, 'app/.matrix'))).rejects.toMatchObject({ code: 'ENOENT' }) + // Explicit preparation still discovers the entire overlapping ownership group. + await runCli(['prepare', 'alpha']) + expect(await readFile(path.join(cwd, 'app/.matrix/types/matrix-runtime.d.ts'), 'utf8')).toContain('readonly beta: string') + }) + + it('rejects converging external roots without overwriting and accepts aligned roots with external config files', async () => { + const cwd = await workspace() + const app = path.join(cwd, 'app') + await writeFile(path.join(app, 'entry.mjs'), 'import { matrix } from "virtual:matrix/runtime"; export const config = matrix.config;') + await writeFile(path.join(app, 'build.mjs'), 'import { build } from "vite"; await build({ configFile: "../configs/" + process.env.MATRIX_PROJECT + "/vite.config.mjs" });') + for (const name of ['a', 'b']) { + await mkdir(path.join(cwd, 'configs', name), { recursive: true }) + await writeFile(path.join(cwd, 'configs', name, 'vite.config.mjs'), ` + import matrix from ${JSON.stringify(plugin)}; + export default { + root: ${JSON.stringify(app)}, envPrefix: 'APP_', plugins: [matrix({ types: true })], logLevel: 'silent', + build: { write: false, lib: { entry: ${JSON.stringify(path.join(app, 'entry.mjs'))}, formats: ['es'] } }, + }; + `) + } + const configure = async (aligned: boolean, overlapping = false): Promise => { + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + projects: Object.fromEntries(['a', 'b'].map(name => [name, { + root: aligned ? (overlapping && name === 'b' ? '.' : 'app') : `configs/${name}`, + ...(aligned ? { configFile: `${overlapping && name === 'b' ? '' : '../'}configs/${name}/vite.config.mjs` } : {}), + targets: { build: 'node build.mjs' }, + }])), + products: { + alpha: { env: { APP_ALPHA: 'alpha' }, variants: { web: 'a' } }, + beta: { env: { APP_BETA: 'beta' }, variants: { web: 'b' } }, + }, + })}`) + } + await configure(true) + await runCli(['prepare', '--env', 'production']) + const output = path.join(app, '.matrix/types/matrix-runtime.d.ts') + const complete = await readFile(output, 'utf8') + expect(complete).toContain('readonly alpha?: string') + expect(complete).toContain('readonly beta?: string') + await configure(false) + for (const command of [['prepare'], ['prepare', 'alpha'], ['build', 'alpha']]) { + await expect(runCli(command)).rejects.toThrow(/ownership.*project.root.*project.configFile/) + expect(await readFile(output, 'utf8')).toBe(complete) + } + await configure(true) + await runCli(['build', 'alpha']) + expect(await readFile(output, 'utf8')).toBe(complete) + // An ancestor project can also own this output; selected collection includes it. + await configure(true, true) + await runCli(['build', 'alpha']) + expect(await readFile(output, 'utf8')).toBe(complete) + }) + + it.each(['parent', 'symlink'])('rejects %s output escapes before writing any declaration, including missing child directories', async (escape) => { + const cwd = await workspace() + const app = path.join(cwd, 'app') + await mkdir(path.join(cwd, 'outside')) + if (escape === 'symlink') + await symlink(path.join(cwd, 'outside'), path.join(app, 'redirect'), 'junction') + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default { + projects: { web: { root: 'app', targets: { build: 'node unused.mjs' } } }, + products: { alpha: { env: { APP_ALPHA: 'alpha' }, variants: { web: 'web' } } }, + }`) + const configure = async (output: string): Promise => { + await writeFile(path.join(app, 'vite.config.mjs'), `import matrix from ${JSON.stringify(plugin)}; + export default { envPrefix: 'APP_', plugins: [matrix({ scope: 'first', types: true }), matrix({ scope: 'second', types: { output: ${JSON.stringify(output)} } })] }; + `) + } + await mkdir(path.join(app, '.matrix/types'), { recursive: true }) + const first = path.join(app, '.matrix/types/matrix-runtime-first.d.ts') + await writeFile(first, '// untouched') + await configure(escape === 'parent' ? '../outside/missing/runtime.d.ts' : 'redirect/missing/runtime.d.ts') + await expect(runCli(['prepare', 'alpha'])).rejects.toThrow('Cannot establish Matrix type output ownership') + expect(await readFile(first, 'utf8')).toBe('// untouched') + await expect(access(path.join(cwd, 'outside/missing'))).rejects.toMatchObject({ code: 'ENOENT' }) + await configure('custom/missing/runtime.d.ts') + await runCli(['prepare', 'alpha']) + expect(await readFile(path.join(app, 'custom/missing/runtime.d.ts'), 'utf8')).toContain('readonly alpha: string') + }) +}) diff --git a/test/typegen/prepare-types.test.ts b/test/typegen/prepare-types.test.ts index 01495f3..2961b83 100644 --- a/test/typegen/prepare-types.test.ts +++ b/test/typegen/prepare-types.test.ts @@ -42,7 +42,7 @@ async function workspace(config: string, project: { root?: string, configFile?: ' APP_OPTIONAL: { type: "string", optional: true },', ' PRIVATE_REQUIRED: { type: "string" },', ' },', - ` projects: { web: ${JSON.stringify({ ...project, targets: { dev: 'node should-not-run.mjs' } })} },`, + ` projects: { web: ${JSON.stringify({ ...project, targets: { dev: 'node should-not-run.mjs', build: 'node should-not-run.mjs' } })} },`, ' products: {', ' alpha: { env: { APP_ALPHA: "alpha-private-value" }, variants: { web: "web" } },', ' beta: { env: { APP_BETA: "beta-private-value" }, variants: { web: "web" } },', @@ -70,7 +70,8 @@ async function genericWorkspace(): Promise { return cwd } -describe('host-aware type preparation', () => { +// Each product now resolves both host lifecycles in isolated processes. +describe('host-aware type preparation', { timeout: 15_000 }, () => { it.each([ ['readdir', 'SIGINT', 130], ['writeFile', 'SIGTERM', 143], @@ -130,7 +131,7 @@ describe('host-aware type preparation', () => { expect(await readFile(earlier, 'utf8')).toContain('Generated by') }) - it('summarizes SIGINT cancellation only after the type worker has exited', async () => { + it.each(['prepare', 'build'])('summarizes %s SIGINT cancellation only after the type worker has exited', async (command) => { const previousExitCode = process.exitCode const cwd = await workspace([ 'import { writeFileSync } from "node:fs";', @@ -141,7 +142,7 @@ describe('host-aware type preparation', () => { '};', ].join(String.fromCharCode(10))) let completed = false - const running = runCli(['prepare', 'alpha']).finally(() => { + const running = runCli([command, 'alpha']).finally(() => { completed = true }) void running.catch(() => undefined) @@ -177,7 +178,7 @@ describe('host-aware type preparation', () => { VITE_OPTIONAL: { type: 'string', optional: true }, PRIVATE_REQUIRED: { type: 'string' }, }, - projects: { web: { targets: { dev: 'node should-not-run.mjs' } } }, + projects: { web: { targets: { dev: 'node should-not-run.mjs', build: 'node should-not-run.mjs' } } }, products: { alpha: { env: { VITE_ALPHA: 'alpha-private-value' }, variants: { web: 'web' } }, beta: { env: { VITE_BETA: 'beta-private-value' }, variants: { web: 'web' } }, @@ -188,7 +189,7 @@ describe('host-aware type preparation', () => { await runCli(['prepare']) const output = path.join(cwd, '.matrix/types/matrix-runtime.d.ts') const generic = await readFile(output, 'utf8') - for (const field of ['alpha: string', 'beta: string', 'enabled: boolean', 'optional?: string']) + for (const field of ['alpha?: string', 'beta?: string', 'enabled: boolean', 'optional?: string']) expect(generic).toContain(`readonly ${field}`) expect(generic).toContain('readonly VITE_ENABLED: string') expect(generic).not.toMatch(/private-value|PRIVATE_REQUIRED/) @@ -208,12 +209,15 @@ describe('host-aware type preparation', () => { 'for (const [key, value] of Object.entries(expected)) {', ' if (process.env[key] !== String(value)) throw new Error("Incorrect context: " + key);', '}', + 'if (!["dev", "build"].includes(process.env.MATRIX_TARGET)) throw new Error("Incorrect target context");', + 'const nodeEnv = process.env.MATRIX_TARGET === "dev" ? "development" : "production";', + 'if (process.env.NODE_ENV !== nodeEnv || process.env.MATRIX_NODE_ENV !== nodeEnv) throw new Error("Incorrect Node context");', 'export default { plugins: [matrix()] };', ].join('\n')) const config = { env: { ...env, MATRIX_PRODUCT_NAME: 'Override' }, suffixes: { staging: { name: ' Global', slug: '-global', appId: '.global' } }, - projects: { web: { targets: { dev: 'node should-not-run.mjs' } } }, + projects: { web: { targets: { dev: 'node should-not-run.mjs', build: 'node should-not-run.mjs' } } }, products: { app: { id: 'example', @@ -238,7 +242,7 @@ describe('host-aware type preparation', () => { MATRIX_PRODUCT_VERSION: version, }) const expected = Object.fromEntries(Object.entries(task.env).filter(([key]) => key.startsWith('MATRIX_') || key === 'NODE_ENV')) - await writeFile(path.join(cwd, 'expected.json'), JSON.stringify({ ...expected, MATRIX_TARGET: 'prepare', MATRIX_NODE_ENV: 'development', NODE_ENV: 'development' })) + await writeFile(path.join(cwd, 'expected.json'), JSON.stringify(Object.fromEntries(Object.entries(expected).filter(([key]) => !['MATRIX_TARGET', 'MATRIX_NODE_ENV', 'NODE_ENV'].includes(key))))) await runCli(['prepare', '--env', 'staging']) expect(await readFile(path.join(cwd, '.matrix/types/matrix-runtime.d.ts'), 'utf8')).toContain('declare module \'virtual:matrix/runtime\'') }) @@ -247,8 +251,8 @@ describe('host-aware type preparation', () => { const cwd = await workspace([ 'import { scope } from "./settings.mjs";', 'export default async ({ command, mode }) => {', - ' if (command !== "serve" || mode !== "staging") throw new Error("Wrong preparation context");', - ' if (process.env.MATRIX_TARGET !== "prepare" || process.env.MATRIX_VARIANT !== "web") throw new Error("Missing Matrix context");', + ' if (!["serve", "build"].includes(command) || mode !== "staging") throw new Error("Wrong preparation context");', + ' if (process.env.MATRIX_TARGET !== (command === "serve" ? "dev" : "build") || process.env.MATRIX_VARIANT !== "web") throw new Error("Missing Matrix context");', ' if (process.env.MATRIX_PREPARE_LEAK) throw new Error("Config process was reused");', ' process.env.MATRIX_PREPARE_LEAK = "local";', ' setInterval(() => {}, 1000);', @@ -270,7 +274,7 @@ describe('host-aware type preparation', () => { const declaration = await readFile(path.join(cwd, 'app/.matrix/types/custom.d.ts'), 'utf8') expect(declaration).toContain('declare module \'virtual:matrix/runtime/web\'') - for (const field of ['alpha: string', 'beta: string', 'local: string', 'enabled: boolean', 'required: number', 'optional?: string']) + for (const field of ['alpha?: string', 'beta?: string', 'local: string', 'enabled: boolean', 'required: number', 'optional?: string']) expect(declaration).toContain(`readonly ${field}`) expect(declaration).not.toMatch(/private-value|hidden-value|OTHER_HIDDEN|PRIVATE_REQUIRED|MATRIX_PREPARE_LEAK/) expect(process.env.MATRIX_PREPARE_LEAK).toBeUndefined() @@ -311,8 +315,31 @@ describe('host-aware type preparation', () => { await runCli(['prepare']) const declaration = await readFile(path.join(cwd, 'app/.matrix/types/matrix-runtime.d.ts'), 'utf8') - expect(declaration).toContain('readonly alpha: string') - expect(declaration).toContain('readonly beta: string') + expect(declaration).toContain('readonly alpha?: string') + expect(declaration).toContain('readonly beta?: string') + }) + + it('collects every logical project sharing a physical root during selected prepare and build', async () => { + const cwd = await workspace('export default { envPrefix: "APP_", plugins: [matrix({ types: true })] };') + await symlink(cwd, path.join(cwd, 'linked-project'), 'junction') + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default { + projects: { + web: { targets: { build: 'node entry.mjs' } }, + alias: { root: 'linked-project', targets: { build: 'node entry.mjs' } }, + }, + products: { + alpha: { env: { APP_ALPHA: 'alpha' }, variants: { web: 'web' } }, + beta: { env: { APP_BETA: 'beta' }, variants: { web: 'alias' } }, + }, + }`) + await writeFile(path.join(cwd, 'entry.mjs'), '') + await runCli(['prepare', 'alpha']) + const filename = path.join(cwd, '.matrix/types/matrix-runtime.d.ts') + const shared = await readFile(filename, 'utf8') + expect(shared).toContain('readonly alpha?: string') + expect(shared).toContain('readonly beta?: string') + await runCli(['build', 'alpha']) + expect(await readFile(filename, 'utf8')).toBe(shared) }) it('honors types:false instead of falling back to generic declarations', async () => { @@ -349,8 +376,8 @@ describe('host-aware type preparation', () => { await writeFile(path.join(projectRoot, file), 'throw new Error("Explicit config must take precedence");') await runCli(['prepare']) const declaration = await readFile(path.join(projectRoot, '.matrix/types/matrix-runtime-selected.d.ts'), 'utf8') - expect(declaration).toContain('readonly alpha: string') - expect(declaration).toContain('readonly beta: string') + expect(declaration).toContain('readonly alpha?: string') + expect(declaration).toContain('readonly beta?: string') await expect(access(path.join(cwd, '.matrix'))).rejects.toMatchObject({ code: 'ENOENT' }) await writeFile(path.join(projectRoot, 'config/vite.config.mjs'), 'throw new Error("Selected config failed");') await expect(runCli(['prepare'])).rejects.toThrow('Selected config failed') diff --git a/test/typegen/shared-contract.test.ts b/test/typegen/shared-contract.test.ts new file mode 100644 index 0000000..4caed54 --- /dev/null +++ b/test/typegen/shared-contract.test.ts @@ -0,0 +1,372 @@ +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import path from 'pathe' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runCli } from '../../src/cli/index.js' + +const execute = promisify(execFile) +const originalCwd = process.cwd() +const directories: string[] = [] +const plugin = fileURLToPath(new URL('../../src/unplugin/vite.ts', import.meta.url)) +const cli = new URL('../../src/cli/index.ts', import.meta.url).href + +beforeEach(() => { + vi.stubEnv('VITEST', undefined) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + process.chdir(originalCwd) + for (const directory of directories.splice(0)) + await rm(directory, { recursive: true, force: true }) +}) + +async function configure(cwd: string, products = ['alpha', 'beta'], fields = true, admin = false): Promise { + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + env: { APP_SHARED: 'shared-value', ADMIN_SHARED: 'admin-value' }, + envSchema: { APP_OPTIONAL: { type: 'string', optional: true } }, + projects: { + web: { ...(admin ? { root: 'web' } : {}), targets: { build: 'node build.mjs', dev: 'node dev.mjs' } }, + ...(admin ? { admin: { root: 'admin', targets: { build: 'node build.mjs' } } } : {}), + }, + products: Object.fromEntries(products.map(name => [name, { + env: fields ? { [`APP_${name.toUpperCase()}`]: `${name}-private-value` } : {}, + variants: { web: 'web', ...(admin ? { admin: 'admin' } : {}) }, + }])), + })}`) +} + +async function workspace(conditional = false, admin = false): Promise { + const cwd = path.normalize(await realpath(await mkdtemp(path.join(os.tmpdir(), 'matrix-shared-contract-')))) + directories.push(cwd) + await symlink(fileURLToPath(new URL('../../node_modules', import.meta.url)), path.join(cwd, 'node_modules'), 'junction') + await writeFile(path.join(cwd, 'package.json'), '{"type":"module"}') + await configure(cwd, ['alpha', 'beta'], true, admin) + const scopes = [['web', 'APP_', admin ? path.join(cwd, 'web') : cwd], ...(admin ? [['admin', 'ADMIN_', path.join(cwd, 'admin')]] : [])] + for (const [scope, prefix, directory] of scopes as [string, string, string][]) { + await mkdir(directory, { recursive: true }) + await writeFile(path.join(directory, 'build.mjs'), 'import { build } from "vite"; await build();') + await writeFile(path.join(directory, 'entry.mjs'), ` + import { matrix } from 'virtual:matrix/runtime/${scope}'; + export const snapshot = { config: matrix.config, product: matrix.product.key }; + console.log(JSON.stringify(snapshot)); + `) + await writeFile(path.join(directory, 'vite.config.mjs'), ` + import matrix from ${JSON.stringify(plugin)}; + export default ({ command }) => ({ + ${conditional && scope === 'web' ? 'envDir: command === "serve" ? "development-env" : "production-env",' : ''} + plugins: [matrix({ scope: '${scope}', envPrefix: '${prefix}' })], + build: { + outDir: 'dist-' + process.env.MATRIX_PRODUCT_KEY, + lib: { entry: 'entry.mjs', formats: ['es'], fileName: () => 'entry.mjs' }, + minify: false, + }, + }); + `) + } + await writeFile(path.join(cwd, 'dev.mjs'), ` + import { rename, writeFile } from 'node:fs/promises'; + import { writeFileSync } from 'node:fs'; + import { createServer } from 'vite'; + const server = await createServer({ server: { host: '127.0.0.1', port: 0 } }); + server.httpServer.once('close', () => writeFileSync('dev-cleanup', 'closed')); + await server.listen(); + const { snapshot } = await server.ssrLoadModule('/entry.mjs'); + // Publish only the complete payload: file existence is the readiness signal. + await writeFile('dev-ready.tmp', JSON.stringify({ url: server.resolvedUrls.local[0], snapshot })); + await rename('dev-ready.tmp', 'dev-ready'); + `) + if (conditional) { + for (const [directory, key] of [['development-env', 'APP_DEV_ONLY'], ['production-env', 'APP_BUILD_ONLY']]) { + await mkdir(path.join(cwd, directory!)) + await writeFile(path.join(cwd, directory!, '.env'), `${key}=branch-private-value\n`) + } + } + process.chdir(cwd) + return cwd +} + +async function declaration(cwd: string, scope = 'web'): Promise { + return readFile(path.join(cwd, scope === 'admin' ? 'admin' : '.', `.matrix/types/matrix-runtime-${scope}.d.ts`), 'utf8') +} + +function expectSharedContract(types: string): void { + for (const field of ['alpha?: string', 'beta?: string', 'optional?: string', 'shared: string']) + expect(types).toContain(`readonly ${field}`) + for (const key of ['APP_ALPHA', 'APP_BETA', 'APP_OPTIONAL']) + expect(types).toContain(`readonly ${key}?: string`) + expect(types).not.toMatch(/private-value|admin-value|ADMIN_SHARED/) +} + +async function runtime(cwd: string, product: string, admin = false): Promise { + const snapshots = await Promise.all((admin ? ['web', 'admin'] : ['.']).map(async (directory) => { + const { stdout } = await execute(process.execPath, [path.join(cwd, directory, `dist-${product}/entry.mjs`)], { cwd }) + return JSON.parse(stdout.trim()) + })) + expect(snapshots[0].product).toBe(product) + if (admin) + expect(snapshots[1].product).toBe(product) + return { product, web: snapshots[0].config, ...(admin ? { admin: snapshots[1].config } : {}) } +} + +function isolatedCli(cwd: string, args: string[], instrumentation = '') { + // Node strips TypeScript but does not map the source tree's .js imports to .ts. + const sourceLoader = ` + import { registerHooks } from 'node:module'; + registerHooks({ resolve(specifier, context, next) { + try { return next(specifier, context); } + catch (error) { + if (error.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.endsWith('.js')) throw error; + return next(specifier.slice(0, -3) + '.ts', context); + } + } }); + ${instrumentation} + const { runCli } = await import(${JSON.stringify(cli)}); + await runCli(${JSON.stringify(args)}); + ` + return execute(process.execPath, ['--experimental-transform-types', '--input-type=module', '-e', sourceLoader], { + cwd, + env: { ...process.env }, + timeout: 25_000, + }) +} + +describe('shared project declaration contract', () => { + it('keeps the full prepared contract through single-product builds and switches without leaking runtime values', async () => { + const cwd = await workspace(false, true) + // Sibling roots keep this scope-isolation fixture independent of the + // separate overlapping-root ownership regressions. + const web = path.join(cwd, 'web') + await runCli(['prepare']) + const prepared = await declaration(web) + const admin = await declaration(cwd, 'admin') + expect(admin).toContain('readonly shared: string') + expect(admin).not.toMatch(/APP_ALPHA|APP_BETA|readonly alpha|readonly beta/) + + for (const product of ['alpha', 'beta']) { + await runCli(['build', product]) + const built = await declaration(web) + expectSharedContract(built) + expect(built).toBe(prepared) + expect(await declaration(cwd, 'admin')).toBe(admin) + expect(await runtime(cwd, product, true)).toEqual({ + product, + web: { shared: 'shared-value', [product]: `${product}-private-value` }, + admin: { shared: 'admin-value' }, + }) + } + }, 30_000) + + it('includes every associated product during selected preparation and removes stale fields after edits', async () => { + const cwd = await workspace() + await runCli(['prepare', 'alpha']) + expectSharedContract(await declaration(cwd)) + + await configure(cwd, ['alpha']) + await runCli(['prepare', 'alpha']) + const reduced = await declaration(cwd) + expect(reduced).toContain('readonly alpha: string') + expect(reduced).not.toMatch(/APP_BETA|readonly beta/) + + await configure(cwd, ['alpha'], false) + await runCli(['prepare', 'alpha']) + const cleared = await declaration(cwd) + expect(cleared).toContain('readonly shared: string') + expect(cleared).not.toMatch(/APP_ALPHA|APP_BETA|readonly alpha|readonly beta/) + }, 30_000) + + it('includes real serve and build config branches in one stable contract', async () => { + const cwd = await workspace(true) + await runCli(['prepare', 'alpha']) + const prepared = await declaration(cwd) + expectSharedContract(prepared) + expect(prepared).toContain('readonly devOnly?: string') + expect(prepared).toContain('readonly buildOnly?: string') + await runCli(['build', 'beta']) + expect(await declaration(cwd)).toBe(prepared) + expect(await runtime(cwd, 'beta')).toEqual({ + product: 'beta', + web: { shared: 'shared-value', beta: 'beta-private-value', buildOnly: 'branch-private-value' }, + }) + }, 30_000) + + it('keeps all three real electron-vite scope contracts across shared-product builds', async () => { + const cwd = await workspace() + await rm(path.join(cwd, 'node_modules')) + await mkdir(path.join(cwd, 'node_modules')) + // Source-config bundling externalizes Matrix's dependencies into the fixture. + // Link each package's physical directory, including scoped dependencies: + // linking a scope directory can leave its relative pnpm links unresolved on Windows. + const modules = fileURLToPath(new URL('../../node_modules', import.meta.url)) + const { dependencies } = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')) + for (const name of Object.keys(dependencies)) { + const destination = path.join(cwd, 'node_modules', name) + await mkdir(path.dirname(destination), { recursive: true }) + await symlink(await realpath(path.join(modules, name)), destination, 'junction') + } + // Keep the example's real electron-vite/Vite pair instead of the root host. + for (const name of ['vite', 'electron-vite']) + await symlink(await realpath(fileURLToPath(new URL(`../../examples/electron-web/node_modules/${name}`, import.meta.url))), path.join(cwd, 'node_modules', name), 'junction') + await rm(path.join(cwd, 'vite.config.mjs')) + await writeFile(path.join(cwd, 'matrix.config.mjs'), `export default ${JSON.stringify({ + env: { VITE_SHARED: 'shared-value', MAIN_VITE_MAIN_ONLY: 'main-private-value', PRELOAD_VITE_PRELOAD_ONLY: 'preload-private-value' }, + projects: { desktop: { targets: { build: 'node build.mjs' } } }, + products: Object.fromEntries(['alpha', 'beta'].map(product => [product, { + env: { [`VITE_${product.toUpperCase()}`]: `${product}-private-value` }, + variants: { desktop: 'desktop' }, + }])), + })}`) + await writeFile(path.join(cwd, 'build.mjs'), 'import { build } from "electron-vite"; await build();') + for (const scope of ['main', 'preload', 'renderer']) { + await writeFile(path.join(cwd, `${scope}-entry.ts`), ` + import { matrix } from 'virtual:matrix/runtime${scope === 'renderer' ? '' : `/${scope}`}'; + console.log(JSON.stringify({ product: matrix.product.key, config: matrix.config })); + `) + } + await writeFile(path.join(cwd, 'index.html'), '') + await writeFile(path.join(cwd, 'electron.vite.config.mjs'), ` + import matrix from ${JSON.stringify(plugin)}; + export default { + main: { + plugins: [matrix({ scope: 'main' })], + build: { outDir: 'out-' + process.env.MATRIX_PRODUCT_KEY + '/main', lib: { entry: 'main-entry.ts', fileName: () => 'entry.cjs' } }, + }, + preload: { + plugins: [matrix({ scope: 'preload' })], + build: { outDir: 'out-' + process.env.MATRIX_PRODUCT_KEY + '/preload', lib: { entry: 'preload-entry.ts', fileName: () => 'entry.cjs' } }, + }, + renderer: { + root: '.', plugins: [matrix()], + build: { outDir: 'out-' + process.env.MATRIX_PRODUCT_KEY + '/renderer', rollupOptions: { input: 'index.html' }, minify: false }, + }, + }; + `) + const files = ['matrix-runtime-main.d.ts', 'matrix-runtime-preload.d.ts', 'matrix-runtime.d.ts'] + const types = () => Promise.all(files.map(file => readFile(path.join(cwd, '.matrix/types', file), 'utf8'))) + await runCli(['prepare', 'alpha']) + const prepared = await types() + for (const contract of prepared) { + expect(contract).toContain('readonly alpha?: string') + expect(contract).toContain('readonly beta?: string') + expect(contract).not.toContain('private-value') + } + expect(prepared[0]).toContain('readonly mainOnly: string') + expect(prepared[0]).not.toMatch(/preloadOnly|PRELOAD_VITE/) + expect(prepared[1]).toContain('readonly preloadOnly: string') + expect(prepared[1]).not.toMatch(/mainOnly|MAIN_VITE/) + expect(prepared[2]).not.toMatch(/mainOnly|preloadOnly|MAIN_VITE|PRELOAD_VITE/) + for (const product of ['alpha', 'beta']) { + await runCli(['build', product]) + expect(await types()).toEqual(prepared) + for (const scope of ['main', 'preload']) { + const directory = path.join(cwd, `out-${product}`, scope) + const entry = (await readdir(directory)).find(file => /\.(?:cjs|m?js)$/.test(file))! + const { stdout } = await execute(process.execPath, [path.join(directory, entry)], { cwd }) + expect(JSON.parse(stdout.trim())).toEqual({ + product, + config: { shared: 'shared-value', [product]: `${product}-private-value`, [`${scope}Only`]: `${scope}-private-value` }, + }) + } + const assets = path.join(cwd, `out-${product}/renderer/assets`) + const renderer = (await Promise.all((await readdir(assets)).filter(file => file.endsWith('.js')).map(file => readFile(path.join(assets, file), 'utf8')))).join('\n') + expect(renderer).toContain(`${product}-private-value`) + expect(renderer).not.toContain(`${product === 'alpha' ? 'beta' : 'alpha'}-private-value`) + expect(renderer).not.toMatch(/main-private-value|preload-private-value/) + } + }, 30_000) + + it.skipIf(process.platform === 'win32')('keeps the serve contract and runtime stable until SIGINT closes the real dev server', async () => { + const cwd = await workspace(true) + await runCli(['prepare', 'alpha']) + const prepared = await declaration(cwd) + const running = isolatedCli(cwd, ['dev', 'beta']) + // Attach a rejection handler immediately: SIGINT intentionally exits with 130. + const stopped = running.catch(error => error) + try { + const ready = JSON.parse(await waitForFile(path.join(cwd, 'dev-ready'))) + expect((await fetch(new URL('/entry.mjs', ready.url))).status).toBe(200) + expect(ready.snapshot).toEqual({ + product: 'beta', + config: { shared: 'shared-value', beta: 'beta-private-value', devOnly: 'branch-private-value' }, + }) + expectSharedContract(await declaration(cwd)) + expect(await declaration(cwd)).toBe(prepared) + running.child.kill('SIGINT') + const result = await stopped + expect(result.code).toBe(130) + expect(await readFile(path.join(cwd, 'dev-cleanup'), 'utf8')).toBe('closed') + await expect(fetch(new URL('/entry.mjs', ready.url))).rejects.toThrow() + expect(await declaration(cwd)).toBe(prepared) + } + finally { + if (running.child.exitCode === null) + running.child.kill('SIGINT') + await stopped + } + }, 30_000) + + it('preserves the contract when independent CLI processes demonstrably contend on the same lock', async () => { + const cwd = await workspace() + const owner = isolatedCli(cwd, ['prepare', 'alpha'], lockBarrier('owner')) + const ownerDone = owner + void ownerDone.catch(() => {}) + let contender: ReturnType | undefined + try { + await waitForFile(path.join(cwd, 'owner-acquired')) + contender = isolatedCli(cwd, ['build', 'beta'], lockBarrier('contender')) + void contender.catch(() => {}) + // Written only when the second process receives EEXIST from the actual + // lock mkdir. The first process cannot collect or publish before release. + await waitForFile(path.join(cwd, 'contender-waiting')) + await expect(readFile(path.join(cwd, '.matrix/types/matrix-runtime-web.d.ts'))).rejects.toMatchObject({ code: 'ENOENT' }) + await writeFile(path.join(cwd, 'release-owner'), '') + await Promise.all([ownerDone, contender]) + expectSharedContract(await declaration(cwd)) + expect(await runtime(cwd, 'beta')).toEqual({ + product: 'beta', + web: { shared: 'shared-value', beta: 'beta-private-value' }, + }) + } + finally { + await writeFile(path.join(cwd, 'release-owner'), '') + await Promise.allSettled([ownerDone, ...(contender ? [contender] : [])]) + } + }, 30_000) +}) + +async function waitForFile(file: string): Promise { + let content = '' + await vi.waitFor(async () => { + content = await readFile(file, 'utf8') + }, { timeout: 15_000, interval: 20 }) + return content +} + +function lockBarrier(role: 'owner' | 'contender'): string { + return ` + const fs = (await import('node:fs/promises')).default; + const { syncBuiltinESMExports } = await import('node:module'); + const mkdir = fs.mkdir; + fs.mkdir = async function (directory, ...args) { + if (!String(directory).match(/matrix-types-.*\\.lock$/)) return mkdir(directory, ...args); + try { + const result = await mkdir(directory, ...args); + if (${JSON.stringify(role)} === 'owner') { + await fs.writeFile('owner-acquired', ''); + while (true) { + try { await fs.access('release-owner'); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + } + return result; + } catch (error) { + if (error.code === 'EEXIST') await fs.writeFile(${JSON.stringify(`${role}-waiting`)}, ''); + throw error; + } + }; + syncBuiltinESMExports(); + ` +} diff --git a/test/typegen/typegen.test.ts b/test/typegen/typegen.test.ts index 91826a3..e34c9ba 100644 --- a/test/typegen/typegen.test.ts +++ b/test/typegen/typegen.test.ts @@ -4,7 +4,9 @@ import { fileURLToPath } from 'node:url' import path from 'pathe' import ts from 'typescript' import { afterEach, describe, expect, it } from 'vitest' -import { generateMatrixTypes, matrixRuntimeModuleId } from '../../src/typegen/generate.js' +import { generateMatrixTypes, matrixRuntimeModuleId, renderMatrixTypes } from '../../src/typegen/generate.js' + +import { mergePreparedTypes } from '../../src/typegen/prepare.js' const temporaryDirectories: string[] = [] @@ -14,6 +16,118 @@ afterEach(async () => { }) describe('matrix type generation', () => { + it('marks inferred fields absent from a product or context optional without weakening schema', () => { + const common = { + cwd: '/shared', + envSchema: { + VITE_REQUIRED: { type: 'number' as const }, + VITE_DEFAULT: { type: 'boolean' as const, default: false }, + VITE_OPTIONAL: { type: 'string' as const, optional: true }, + }, + } + const batches = [ + [{ ...common, env: { VITE_ALPHA: '', VITE_SHARED: '', VITE_REQUIRED: '' } }], + [{ ...common, env: { VITE_BETA: '', VITE_SHARED: '' } }], + ] + const declarations = mergePreparedTypes(batches).map(renderMatrixTypes) + expect(mergePreparedTypes([...batches].reverse()).map(renderMatrixTypes)).toEqual(declarations) + const declaration = declarations[0]! + for (const field of ['alpha?: string', 'beta?: string', 'shared: string', 'required: number', 'default: boolean', 'optional?: string', 'VITE_ALPHA?: string']) + expect(declaration).toContain(`readonly ${field}`) + }) + + it('reconciles global raw env presence without merging scoped config contracts', async () => { + const project = await mkdtemp(path.join(os.tmpdir(), 'matrix-scoped-types-')) + temporaryDirectories.push(project) + const main = { + cwd: path.join(project, 'main'), + typeProjectRoot: project, + scope: 'main', + env: { VITE_CONTEXT: '', VITE_MAIN_ONLY: '', VITE_ALWAYS: '' }, + envSchema: { + VITE_TYPED: { type: 'enum' as const, values: ['main', 'worker'] as const }, + VITE_DEFAULT: { type: 'boolean' as const, default: false }, + }, + } + const renderer = { + cwd: path.join(project, 'renderer'), + typeProjectRoot: project, + scope: 'renderer', + env: { VITE_CONTEXT: '', VITE_ALWAYS: '' }, + envSchema: { + VITE_TYPED: { type: 'number' as const }, + VITE_DEFAULT: { type: 'string' as const, optional: true }, + }, + } + const batches = [[main, renderer], [main, { ...renderer, env: { VITE_ALWAYS: '' } }]] + const declarations = mergePreparedTypes(batches) + const rendered = declarations.map(renderMatrixTypes) + expect(mergePreparedTypes([...batches].reverse()).map(renderMatrixTypes)).toEqual(rendered) + for (const declaration of rendered) { + expect(declaration).toContain('readonly VITE_CONTEXT?: string') + expect(declaration).toContain('readonly VITE_DEFAULT?: string') + expect(declaration).toContain('readonly VITE_ALWAYS: string') + expect(declaration).toContain('readonly VITE_TYPED: string') + } + expect(rendered[0]).toContain('readonly VITE_MAIN_ONLY?: string') + expect(rendered[0]).toContain('readonly mainOnly: string') + expect(rendered[0]).toContain('readonly context: string') + expect(rendered[1]).toContain('readonly context?: string') + expect(rendered[1]).not.toContain('mainOnly') + expect(rendered[1]).not.toContain('VITE_MAIN_ONLY') + const aliased = mergePreparedTypes(batches.map(batch => batch.map(declaration => ({ + ...declaration, + typeProjectRoot: declaration.scope === 'renderer' ? renderer.cwd : project, + })))) + expect(aliased.map(renderMatrixTypes)).toEqual(rendered) + const reowned = [...batches, [ + { ...main, typeProjectRoot: main.cwd }, + { ...renderer, typeProjectRoot: renderer.cwd, env: { VITE_ALWAYS: '' } }, + ]] + // Later aliases can replace all output contributors; retain the original + // parent ownership when reconciling globals, regardless of batch order. + expect(mergePreparedTypes(reowned).map(renderMatrixTypes)).toEqual(rendered) + expect(mergePreparedTypes([...reowned].reverse()).map(renderMatrixTypes)).toEqual(rendered) + const outputs = await Promise.all(declarations.map(declaration => generateMatrixTypes(declaration))) + const consumer = path.join(project, 'consumer.ts') + await writeFile(consumer, [ + 'import { matrix as main } from "virtual:matrix/runtime/main";', + 'import { matrix as renderer } from "virtual:matrix/runtime/renderer";', + 'declare const raw: ImportMetaEnv;', + 'const common: string = raw.VITE_ALWAYS;', + 'const rawTyped: string = raw.VITE_TYPED;', + 'const scoped: string = main.config.context;', + 'const mainOnly: string = main.config.mainOnly;', + 'const defaulted: boolean = main.config.default;', + 'const mainTyped: "main" | "worker" = main.config.typed;', + 'const rendererTyped: number = renderer.config.typed;', + 'const rendererDefault: string | undefined = renderer.config.default;', + '// @ts-expect-error A key missing in one scope is globally optional.', + 'const unsafeMain: string = raw.VITE_MAIN_ONLY;', + '// @ts-expect-error A key missing in one product/context is globally optional.', + 'const unsafeContext: string = raw.VITE_CONTEXT;', + '// @ts-expect-error Schema optionality differs across scopes.', + 'const unsafeDefault: string = raw.VITE_DEFAULT;', + '// @ts-expect-error Renderer context remains optional.', + 'const unsafeRenderer: string = renderer.config.context;', + '// @ts-expect-error Main-only config must not leak into renderer.', + 'renderer.config.mainOnly;', + ].join('\n')) + const program = ts.createProgram([...outputs, consumer], { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + noEmit: true, + skipLibCheck: false, + types: [], + paths: { '@xova/matrix/runtime': [fileURLToPath(new URL('../../src/runtime/index.ts', import.meta.url))] }, + }) + expect(ts.getPreEmitDiagnostics(program).map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))).toEqual([]) + const separate = mergePreparedTypes([[main, { ...renderer, cwd: `${project}-separate`, typeProjectRoot: `${project}-separate`, env: {} }]]) + expect(renderMatrixTypes(separate[0]!)).toContain('readonly VITE_CONTEXT: string') + }) + it('generates typed public env and virtual runtime declarations without values', async () => { const project = await mkdtemp(path.join(os.tmpdir(), 'matrix-types-')) temporaryDirectories.push(project) diff --git a/test/unplugin/index.test.ts b/test/unplugin/index.test.ts index e10f1fd..2b590e2 100644 --- a/test/unplugin/index.test.ts +++ b/test/unplugin/index.test.ts @@ -9,8 +9,11 @@ import { rollup } from 'rollup' import { build, resolveConfig } from 'vite' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { runCli } from '../../src/cli/index.js' -import { MATRIX_ENV_SCHEMA_KEY } from '../../src/config/env-schema.js' +import { MATRIX_ENV_SCHEMA_KEY, MATRIX_TYPE_CONTRACT_KEY } from '../../src/config/env-schema.js' +import { preparedTypeDeclaration } from '../../src/typegen/coordination.js' +import { generateMatrixTypes } from '../../src/typegen/generate.js' import { MatrixUnplugin } from '../../src/unplugin/index.js' +import { resolveFuturePath } from '../../src/utils/path.js' const temporaryDirectories: string[] = [] const previousCwd = process.cwd() @@ -22,6 +25,7 @@ beforeEach(() => { // Do not let the separate default Vitest suppression satisfy the guard tests. vi.stubEnv('VITEST', undefined) vi.stubEnv(MATRIX_ENV_SCHEMA_KEY, undefined) + vi.stubEnv(MATRIX_TYPE_CONTRACT_KEY, undefined) for (const key of Object.keys(process.env)) { if (key.startsWith('MAIN_VITE_')) vi.stubEnv(key, undefined) @@ -71,6 +75,7 @@ function hostRunner(host: Host, project: string, options?: MatrixUnpluginOptions if (host === 'vite config') { const resolved = await resolveConfig(config, 'serve') expect(resolved.env).not.toHaveProperty(MATRIX_ENV_SCHEMA_KEY) + expect(resolved.env).not.toHaveProperty(MATRIX_TYPE_CONTRACT_KEY) } else { await build({ @@ -86,6 +91,71 @@ function hostRunner(host: Host, project: string, options?: MatrixUnpluginOptions } describe('matrix unplugin', () => { + it('keeps Rollup generation host-local when Vite collection injects an empty strict contract', async () => { + const project = await projectDirectory() + vi.stubEnv(MATRIX_ENV_SCHEMA_KEY, '{}') + // A discoverable Vite config without Matrix plugins produces this handshake. + vi.stubEnv(MATRIX_TYPE_CONTRACT_KEY, JSON.stringify({ strict: true, declarations: [] })) + vi.stubEnv('MAIN_VITE_WINDOW_TITLE', 'Matrix') + await hostRunner('rollup build', project)() + const types = await readFile(path.join(project, output), 'utf8') + expect(types).toContain('readonly windowTitle: string') + expect(types).not.toContain(MATRIX_TYPE_CONTRACT_KEY) + }) + + it('accepts a schema-declared optional field populated only after Vite collection', async () => { + const project = await projectDirectory() + const shared = { + cwd: project, + env: {}, + envSchema: { MAIN_VITE_OPTIONAL: { type: 'string' as const, optional: true } }, + envPrefix: ['MAIN_VITE_', 'MATRIX_', '__MATRIX_'], + } + const filename = await generateMatrixTypes(shared) + const complete = await readFile(filename, 'utf8') + expect(complete).toContain('readonly optional?: string') + const declaration = preparedTypeDeclaration({ ...shared, output: await resolveFuturePath(filename) }) + expect(declaration.keys).toContain('MAIN_VITE_OPTIONAL') + vi.stubEnv(MATRIX_ENV_SCHEMA_KEY, JSON.stringify(shared.envSchema)) + vi.stubEnv(MATRIX_TYPE_CONTRACT_KEY, JSON.stringify({ strict: true, declarations: [declaration] })) + vi.stubEnv('MAIN_VITE_OPTIONAL', 'late-private-value') + await hostRunner('vite config', project)() + expect(await readFile(filename, 'utf8')).toBe(complete) + vi.stubEnv('MAIN_VITE_UNCOLLECTED', 'unexpected') + await expect(hostRunner('vite config', project)()).rejects.toThrow('Matrix type contract changed after collection') + expect(await readFile(filename, 'utf8')).toBe(complete) + }) + + it.each(['direct', 'aliased'])('consumes a complete coordinated contract through a %s root without publishing an active-product subset', async (root) => { + let project = await projectDirectory() + if (root === 'aliased') { + const alias = `${project}-alias` + await symlink(project, alias, 'junction') + temporaryDirectories.push(alias) + project = alias + } + const shared = { + cwd: project, + env: { MAIN_VITE_ALPHA: '', MAIN_VITE_BETA: '' }, + optionalEnvKeys: ['MAIN_VITE_ALPHA', 'MAIN_VITE_BETA'], + envPrefix: ['MAIN_VITE_', 'MATRIX_', '__MATRIX_'], + } + const filename = await generateMatrixTypes(shared) + const complete = await readFile(filename, 'utf8') + // The coordinator publishes canonical output identities, as the host does. + // Temp roots may use aliases (/var or Windows short names). + const declaration = preparedTypeDeclaration({ ...shared, output: await resolveFuturePath(filename) }) + vi.stubEnv(MATRIX_ENV_SCHEMA_KEY, '{}') + vi.stubEnv(MATRIX_TYPE_CONTRACT_KEY, JSON.stringify({ strict: true, declarations: [declaration] })) + vi.stubEnv('MAIN_VITE_ALPHA', 'active') + await hostRunner('vite config', project)() + expect(await readFile(filename, 'utf8')).toBe(complete) + expect(complete).not.toContain(MATRIX_TYPE_CONTRACT_KEY) + vi.stubEnv('MAIN_VITE_UNCOLLECTED', 'unexpected') + await expect(hostRunner('vite config', project, { types: true })()).rejects.toThrow('Matrix type contract changed after collection') + expect(await readFile(filename, 'utf8')).toBe(complete) + }) + it('skips automatic types in Vitest even with Matrix context', async () => { vi.stubEnv('VITEST', 'true') const project = await projectDirectory()