diff --git a/.changeset/render-mode-docker-engine.md b/.changeset/render-mode-docker-engine.md new file mode 100644 index 00000000..64d618fa --- /dev/null +++ b/.changeset/render-mode-docker-engine.md @@ -0,0 +1,5 @@ +--- +'@vivliostyle/cli': minor +--- + +Support `renderMode: docker` without Docker Desktop diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 261b2965..698b632f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,9 @@ on: - synchronize merge_group: +permissions: + contents: read + jobs: build-and-test: runs-on: ${{ matrix.os }} @@ -33,6 +36,182 @@ jobs: - run: pnpm install - run: pnpm build:cli - run: pnpm test + # Runs render-on-docker.test.ts against a locally-built CLI image on Linux, + # where the docker daemon is native (no Docker Desktop, no WSL). + render-on-docker-linux: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install + - run: pnpm build:cli + - name: Output package version + id: package + run: echo "version=$(jq -r '.version' package.json)" >> "$GITHUB_OUTPUT" + - name: Output browser version + id: browser + run: | + BROWSER_VERSION=$(sed -n '/START DEFAULT_BROWSER_VERSIONS/,/END DEFAULT_BROWSER_VERSIONS/p' src/constants.ts | grep -oP 'chrome:\s*\K\{[^}]+\}' | jq -r '.linux') + echo "browser=chrome@$BROWSER_VERSION" >> "$GITHUB_OUTPUT" + - name: Build vivliostyle/cli image + run: | + docker build -t vivliostyle/cli:ci \ + --build-arg VS_CLI_VERSION=${{ steps.package.outputs.version }} \ + --build-arg BROWSER=${{ steps.browser.outputs.browser }} \ + . + - name: Run render-on-docker tests + env: + VIVLIOSTYLE_TEST_IMAGE: vivliostyle/cli:ci + run: | + # The container runs as the Dockerfile's `USER vivliostyle` (uid + # 1000); the runner's checkout dir is owned by a different uid. + # Pre-create the output dir owned by the runner with permissive + # perms so (a) the container can write inside it and (b) vitest's + # global teardown (running as the runner user) can rm + # container-created files afterwards. + mkdir -p tests/fixtures/render-on-docker/.vs-pdf + chmod -R a+rwX tests/fixtures/render-on-docker + npx vitest run tests/render-on-docker.test.ts + + # Runs render-on-docker.test.ts in the Win+WSL hybrid NAT configuration: + # Node runs on Windows; dockerd runs inside a WSL Ubuntu distro; docker.exe + # on Windows is pointed at the WSL daemon via DOCKER_HOST. This is the + # configuration that `createDefaultWslNatRenderMode()` is designed for. + # + # The mirrored networking mode (`createDefaultWslMirroredRenderMode()`) is + # NOT covered here — see u1f992-temp/comment.md for why. + render-on-docker-wsl-nat: + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install + - run: pnpm build:cli + + # Vampire/setup-wsl handles `wsl --install`, distro download, and + # default-user provisioning (which `wsl --install -d` would otherwise + # prompt for interactively). + - name: Set up WSL with Ubuntu + uses: Vampire/setup-wsl@v7 + with: + distribution: Ubuntu-24.04 + set-as-default: 'true' + + - name: Install Docker Engine inside WSL + shell: wsl-bash {0} + run: | + set -eux + curl -fsSL https://get.docker.com -o /tmp/get-docker.sh + sudo sh /tmp/get-docker.sh + # Make dockerd listen on TCP so Windows-side docker.exe can reach + # it. 0.0.0.0:2375 is unauthenticated; acceptable for a one-shot + # CI VM but never for a real host. + sudo mkdir -p /etc/docker + echo '{"hosts":["unix:///var/run/docker.sock","tcp://0.0.0.0:2375"]}' | sudo tee /etc/docker/daemon.json + # WSL doesn't run systemd by default; start dockerd directly. The + # `-H` flags on the command line conflict with daemon.json hosts, + # so override via the systemd-style override-less direct invocation. + sudo nohup dockerd > /tmp/dockerd.log 2>&1 & + # Wait for the socket to come up + for i in $(seq 1 30); do + if sudo docker info > /dev/null 2>&1; then break; fi + sleep 1 + done + if ! sudo docker info > /dev/null 2>&1; then + echo "::group::dockerd log (/tmp/dockerd.log)" + sudo cat /tmp/dockerd.log || true + echo "::endgroup::" + echo "::group::process tree" + ps auxf 2>&1 | grep -E 'docker|containerd' || true + echo "::endgroup::" + echo "::group::docker socket presence" + sudo ls -la /var/run/docker.sock /run/docker.sock 2>&1 || true + echo "::endgroup::" + echo "::group::iptables alternatives state" + update-alternatives --display iptables 2>&1 || true + update-alternatives --display ip6tables 2>&1 || true + echo "::endgroup::" + echo "::group::kernel info" + uname -a + cat /proc/version + echo "::endgroup::" + exit 1 + fi + sudo docker info + + - name: Output package version + id: package + shell: bash + run: echo "version=$(jq -r '.version' package.json)" >> "$GITHUB_OUTPUT" + - name: Output browser version + id: browser + shell: bash + run: | + BROWSER_VERSION=$(sed -n '/START DEFAULT_BROWSER_VERSIONS/,/END DEFAULT_BROWSER_VERSIONS/p' src/constants.ts | grep -oP 'chrome:\s*\K\{[^}]+\}' | jq -r '.linux') + echo "browser=chrome@$BROWSER_VERSION" >> "$GITHUB_OUTPUT" + + - name: Build vivliostyle/cli image inside WSL + shell: wsl-bash {0} + # GitHub Actions `env:` values are set on the Windows-side shell only; + # they do NOT propagate into Vampire/setup-wsl's wsl-bash wrapper. + # Substitute the values directly via YAML expression expansion so the + # script body contains the literals. Single quotes preserve the + # Windows-style backslashes in the workspace path so bash does not + # interpret them as escape sequences. + run: | + set -eux + # The runner workspace is at /mnt/d/...; building from drvfs is + # very slow (small-file I/O over 9P). Copy to the WSL ext4 fs. + WORKSPACE=$(wslpath -u '${{ github.workspace }}') + cp -r "$WORKSPACE" /tmp/repo + cd /tmp/repo + sudo docker build -t vivliostyle/cli:ci \ + --build-arg VS_CLI_VERSION='${{ steps.package.outputs.version }}' \ + --build-arg BROWSER='${{ steps.browser.outputs.browser }}' \ + . + + - name: Allow inbound on vEthernet (WSL) interface + shell: pwsh + run: | + # The WSL VM eth0 default gateway points at this Windows interface; + # Defender Firewall blocks inbound by default, which prevents the + # container from reaching the host Vite. Loosen for this run only. + $iface = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.InterfaceAlias -like 'vEthernet (WSL*' } | + Select-Object -First 1 + if (-not $iface) { throw 'No vEthernet (WSL*) interface found.' } + New-NetFirewallRule -DisplayName 'Vivliostyle CI: WSL inbound' ` + -Direction Inbound ` + -InterfaceAlias $iface.InterfaceAlias ` + -Protocol TCP -Action Allow + + - name: Run render-on-docker WSL hybrid NAT test + shell: pwsh + env: + VIVLIOSTYLE_TEST_IMAGE: vivliostyle/cli:ci + VIVLIOSTYLE_TEST_WSL_HYBRID_NAT: '1' + run: | + # Resolve WSL VM IP and point Windows-side docker.exe (pre-installed + # via Docker Desktop) at the in-WSL dockerd's TCP listener. + $wslIp = (wsl -- hostname -I).Trim().Split(' ')[0] + $env:DOCKER_HOST = "tcp://${wslIp}:2375" + docker version + npx vitest run tests/render-on-docker.test.ts + build-and-push-image: runs-on: ubuntu-latest services: diff --git a/docs/api-javascript.md b/docs/api-javascript.md index fe3d6855..1f50cf7d 100644 --- a/docs/api-javascript.md +++ b/docs/api-javascript.md @@ -7,8 +7,12 @@ - [`build`](#build) - [`create`](#create) +- [`createDefaultWslMirroredRenderMode`](#createdefaultwslmirroredrendermode) +- [`createDefaultWslNatRenderMode`](#createdefaultwslnatrendermode) - [`createVitePlugin`](#createviteplugin) +- [`createWslPathTransformer`](#createwslpathtransformer) - [`defineConfig`](#defineconfig) +- [`getWslHostIp`](#getwslhostip) - [`preview`](#preview) - [`VFM`](#vfm) @@ -196,7 +200,7 @@ build({ ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -428,7 +432,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -504,6 +508,75 @@ Scaffold a new Vivliostyle project. *** +### createDefaultWslMirroredRenderMode() + +> **createDefaultWslMirroredRenderMode**(`options`): `object` + +Build the conventional default `renderMode` fields (without `mode`) for +the WSL hybrid + mirrored networking case. Spread into a `renderMode` +literal: + +```ts +renderMode: { mode: 'docker', ...createDefaultWslMirroredRenderMode() } +``` + +`options` is forwarded to [createWslPathTransformer](#createwslpathtransformer); pass +`{ automountRoot }` if the target WSL distro has changed `automount.root` +in `/etc/wsl.conf`. + +#### Parameters + +##### options + +`WslPathTransformerOptions` = `{}` + +#### Returns + +`object` + +| Name | Type | +| ------ | ------ | +| `extraRunArgs` | readonly \[`"--network=host"`\] | +| `hostGateway` | `"127.0.0.1"` | +| `pathTransformer()` | (`hostPath`) => `string` | + +*** + +### createDefaultWslNatRenderMode() + +> **createDefaultWslNatRenderMode**(`options`): `object` + +Build the conventional default `renderMode` fields (without `mode`) for +the WSL hybrid + NAT networking case. Spread into a `renderMode` literal: + +```ts +renderMode: { mode: 'docker', ...createDefaultWslNatRenderMode() } +``` + +`options` is forwarded to [createWslPathTransformer](#createwslpathtransformer); pass +`{ automountRoot }` if the target WSL distro has changed `automount.root` +in `/etc/wsl.conf`. + +It's a factory so `getWslHostIp()` runs at the call site; the WSL default +gateway can change across VM restarts. + +#### Parameters + +##### options + +`WslPathTransformerOptions` = `{}` + +#### Returns + +`object` + +| Name | Type | +| ------ | ------ | +| `hostGateway` | `string` | +| `pathTransformer()` | (`hostPath`) => `string` | + +*** + ### createVitePlugin() > **createVitePlugin**(`inlineConfig`): `Promise`\<`Plugin`\<`any`\>[]\> @@ -658,7 +731,7 @@ Scaffold a new Vivliostyle project. ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -734,6 +807,61 @@ Scaffold a new Vivliostyle project. *** +### createWslPathTransformer() + +> **createWslPathTransformer**(`__namedParameters`): (`hostPath`) => `string` + +Build a `renderMode.pathTransformer` that translates Windows drive-letter +absolute paths to their WSL drvfs automount counterpart (default +`/mnt//...`). Useful when the docker daemon is upstream moby +running inside a WSL distro. + +Example: +```ts +renderMode: { + mode: 'docker', + pathTransformer: createWslPathTransformer(), + // ... +} +``` + +Contract of the returned transformer: + The input is expected to be an absolute path produced by `upath.resolve()` + (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, + `target.path`, etc.). Under that contract the input is one of: + - POSIX absolute (`/foo/bar`) on Linux/macOS hosts: passed through + - Drive-letter + forward slash (`C:/Users/foo`) on Windows hosts: translated + + Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths + that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, + empty input) violates the contract and throws. + +Pass `{ automountRoot }` if the target WSL distro has changed +`automount.root` in `/etc/wsl.conf` (see WslPathTransformerOptions). +Override does not apply to POSIX inputs; those pass through unchanged. + +#### Parameters + +##### \_\_namedParameters + +`WslPathTransformerOptions` = `{}` + +#### Returns + +> (`hostPath`): `string` + +##### Parameters + +###### hostPath + +`string` + +##### Returns + +`string` + +*** + ### defineConfig() > **defineConfig**(`config`): [`VivliostyleConfigSchema`](#vivliostyleconfigschema) @@ -752,6 +880,22 @@ Define the configuration for Vivliostyle CLI. *** +### getWslHostIp() + +> **getWslHostIp**(): `string` + +Returns the IP at which the Windows host is reachable from inside WSL +(the default gateway of WSL's eth0). Useful as `renderMode.hostGateway` +for the NAT networking mode (the WSL default). + +Windows host only. Caller is responsible for gating on `process.platform`. + +#### Returns + +`string` + +*** + ### preview() > **preview**(`options`): `Promise`\<`ViteDevServer`\> @@ -908,7 +1052,7 @@ Open a browser for previewing the publication. ###### renderMode? -`"local"` \| `"docker"` = `...` +`"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} = `...` ###### sandbox? @@ -1098,7 +1242,7 @@ interface to the schema, so a drift in either direction is rejected. | `proxyUser?` | `string` | | `quick?` | `boolean` | | `readingProgression?` | `"ltr"` \| `"rtl"` | -| `renderMode?` | `"local"` \| `"docker"` | +| `renderMode?` | `"docker"` \| \{ `extraRunArgs?`: `string`[] \| readonly `string`[]; `hostGateway?`: `string`; `mode`: `"docker"`; `pathTransformer?`: (`hostPath`) => `string`; \} \| `"local"` \| \{ `mode`: `"local"`; \} | | `sandbox?` | `boolean` | | `signal?` | `AbortSignal` | | `singleDoc?` | `boolean` | diff --git a/docs/config.md b/docs/config.md index 6ad91e23..6fbf9d90 100644 --- a/docs/config.md +++ b/docs/config.md @@ -379,7 +379,7 @@ type ArticleEntryConfig = { - `format`: "pdf" | "epub" | "webpub" Specifies the output format. - - `renderMode`: "local" | "docker" + - `renderMode`: "local" | "docker" | "{variant(Object)}" If set to `docker`, Vivliostyle will render the PDF using a Docker container. (default: `local`) - ~~`preflight`~~ _Deprecated_ @@ -399,7 +399,10 @@ type ArticleEntryConfig = { type OutputConfig = { path: string; format?: "pdf" | "epub" | "webpub"; - renderMode?: "local" | "docker"; + renderMode?: + | "local" + | "docker" + | "{variant(Object)}"; preflight?: | "press-ready" | "press-ready-local"; diff --git a/src/config/resolve.ts b/src/config/resolve.ts index c096cc7d..e0e32cd8 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -19,6 +19,7 @@ import { CoverEntryConfig, EntryConfig, type InputFormat, + type RenderMode, StructuredDocument, StructuredDocumentSection, ThemeConfig, @@ -275,10 +276,27 @@ export interface ReplaceImageEntry { export type ReplaceImageConfig = ReplaceImageEntry[]; +export type ResolvedRenderMode = + | { + mode: 'docker'; + hostGateway?: string | undefined; + pathTransformer?: ((hostPath: string) => string) | undefined; + extraRunArgs?: readonly string[] | undefined; + } + | { mode: 'local' }; + +function normalizeRenderMode( + input: RenderMode | undefined, +): ResolvedRenderMode { + return input && typeof input === 'object' + ? input + : { mode: input || 'local' }; +} + export interface PdfOutput { format: 'pdf'; path: string; - renderMode: 'local' | 'docker'; + renderMode: ResolvedRenderMode; preflight: 'press-ready' | 'press-ready-local' | undefined; preflightOption: string[]; cmyk: CmykConfig | false; @@ -779,7 +797,7 @@ export function resolveTaskConfig( const defaultPdfOptions: Omit = { format: 'pdf', - renderMode: options.renderMode ?? 'local', + renderMode: normalizeRenderMode(options.renderMode), preflight: resolveDefaultPreflight(), preflightOption: resolveDefaultPreflightOption(), cmyk: resolveCmykConfig(config.pdfPostprocess?.cmyk), @@ -834,6 +852,9 @@ export function resolveTaskConfig( preflightOption: resolvedPreflightOption, cmyk: resolvedCmyk, replaceImage: resolvedReplaceImage, + renderMode: normalizeRenderMode( + target.renderMode ?? options.renderMode, + ), }; } case 'epub': @@ -870,7 +891,8 @@ export function resolveTaskConfig( const port = config.server?.port ?? 13000; if ( outputs.some( - (target) => target.format === 'pdf' && target.renderMode === 'docker', + (target) => + target.format === 'pdf' && target.renderMode.mode === 'docker', ) && !isInContainer() ) { diff --git a/src/config/schema.ts b/src/config/schema.ts index d7101d2b..f7ac174f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -231,7 +231,57 @@ export const OutputFormat = v.union([ ]); export type OutputFormat = v.InferInput; -export const RenderMode = v.union([v.literal('local'), v.literal('docker')]); +export const RenderModeDockerObject = v.pipe( + v.object({ + mode: v.literal('docker'), + hostGateway: v.optional( + v.pipe( + ValidString, + v.description($` + Override the IP that \`host.docker.internal\` resolves to inside the + container. Default: Docker's special token \`host-gateway\`. + `), + ), + ), + pathTransformer: v.optional( + v.pipe( + v.function() as v.GenericSchema<(hostPath: string) => string>, + v.metadata({ typeString: '(hostPath: string) => string' }), + v.description($` + Rewrite the host side of \`-v\` bind paths before they reach dockerd. + Used to translate Windows paths to WSL drvfs form, etc. + `), + ), + ), + extraRunArgs: v.optional( + v.pipe( + v.array(ValidString) as v.GenericSchema, + v.description($` + Additional arguments inserted between \`--rm\` and the image name in + \`docker run\`. Used for WSL mirrored mode (\`['--network=host']\`), + GPU passthrough, etc. + `), + ), + ), + }), + v.title('RenderModeDocker'), + v.description($` + Object form of \`renderMode: 'docker'\`. Use this to tune the docker + invocation when the daemon is not Docker Desktop (e.g. raw Linux Docker + Engine, or dockerd inside a WSL distro). + `), +); +export const RenderModeLocalObject = v.object({ + mode: v.literal('local'), +}); +export const RenderMode = v.union([ + v.literal('local'), + v.literal('docker'), + v.variant('mode', [ + RenderModeDockerObject, + RenderModeLocalObject, + ]), +]); export type RenderMode = v.InferInput; const RGBValueObjectSchema = v.pipe( diff --git a/src/container.ts b/src/container.ts index 384d41dc..76441232 100644 --- a/src/container.ts +++ b/src/container.ts @@ -29,7 +29,10 @@ export function toContainerPath(urlOrAbsPath: string): string { ); } -export function collectVolumeArgs(mountPoints: string[]): string[] { +export function collectVolumeArgs( + mountPoints: string[], + pathTransformer?: (hostPath: string) => string, +): string[] { return mountPoints .filter((p, i, array) => { if (i !== array.indexOf(p)) { @@ -46,7 +49,10 @@ export function collectVolumeArgs(mountPoints: string[]): string[] { } return true; }) - .map((p) => `${p}:${toContainerPath(p)}`); + .map( + (p) => + `${pathTransformer ? pathTransformer(p) : p}:${toContainerPath(p)}`, + ); } export async function runContainer({ @@ -56,6 +62,8 @@ export async function runContainer({ entrypoint, env, workdir, + hostGateway, + extraRunArgs, }: { image: string; userVolumeArgs: string[]; @@ -63,6 +71,8 @@ export async function runContainer({ entrypoint?: string; env?: [string, string][]; workdir?: string; + hostGateway?: string; + extraRunArgs?: readonly string[]; }) { const { default: commandExists } = await importNodeModule('command-exists'); if (!(await commandExists('docker'))) { @@ -80,12 +90,24 @@ export async function runContainer({ ); } + // Collect lines so we can surface the actual container output if the + // process exits non-zero (tinyexec's thrown error only contains exit + // status + spawn args, and Logger.log may be silenced by log level). + const collectedOutput: string[] = []; try { using _ = Logger.suspendLogging('Launching docker container'); const args = [ 'run', ...(Logger.isInteractive ? ['-it'] : []), '--rm', + // Docker Desktop (and Colima) auto-provide host.docker.internal; raw + // Linux dockerd, including the dockerd that runs inside WSL, does not. + // `host-gateway` resolves to the daemon's docker0 bridge by default; the + // hostGateway override lets users point it at a different IP (e.g. the + // WSL eth0 gateway when the daemon lives in WSL but Vite runs on Windows). + '--add-host', + `${CONTAINER_LOCAL_HOSTNAME}:${hostGateway ?? 'host-gateway'}`, + ...(extraRunArgs ?? []), ...(entrypoint ? ['--entrypoint', entrypoint] : []), ...(env ? env.flatMap(([k, v]) => ['-e', `${k}=${v}`]) : []), ...(process.env.DEBUG @@ -108,11 +130,16 @@ export async function runContainer({ } else { for await (const line of proc) { Logger.log(line); + collectedOutput.push(line); } } } catch (error) { + const tail = collectedOutput.slice(-50).join('\n'); throw new Error( - 'An error occurred on the running container. Please see logs above.', + `An error occurred on the running container.${ + tail ? `\nLast container output:\n${tail}` : '' + }`, + { cause: error }, ); } } @@ -145,20 +172,29 @@ export async function buildPDFWithContainer({ host: CONTAINER_LOCAL_HOSTNAME, } satisfies ParsedVivliostyleInlineConfig; + // buildPDFWithContainer is only invoked for docker-mode targets (see build.ts) + const renderMode = + target.renderMode.mode === 'docker' ? target.renderMode : undefined; + await runContainer({ image: config.image, - userVolumeArgs: collectVolumeArgs([ - ...(typeof config.serverRootDir === 'string' - ? [config.serverRootDir] - : []), - upath.dirname(target.path), - ]), + userVolumeArgs: collectVolumeArgs( + [ + ...(typeof config.serverRootDir === 'string' + ? [config.serverRootDir] + : []), + upath.dirname(target.path), + ], + renderMode?.pathTransformer, + ), env: [['VS_CLI_BUILD_PDF_OPTIONS', JSON.stringify(bypassedOption)]], commandArgs: ['build'], workdir: typeof config.serverRootDir === 'string' ? toContainerPath(config.serverRootDir) : undefined, + hostGateway: renderMode?.hostGateway, + extraRunArgs: renderMode?.extraRunArgs, }); return target.path; diff --git a/src/core/build.ts b/src/core/build.ts index d19818d6..86c07bd1 100644 --- a/src/core/build.ts +++ b/src/core/build.ts @@ -109,7 +109,7 @@ export async function build( let output: string | null = null; const { format } = target; if (format === 'pdf') { - if (!containerForkMode && target.renderMode === 'docker') { + if (!containerForkMode && target.renderMode.mode === 'docker') { output = await buildPDFWithContainer({ target, config, diff --git a/src/index.ts b/src/index.ts index 6bd85b6c..c7c1a19e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,12 @@ export type { } from './config/schema.js'; export type { TemplateVariable } from './create-template.js'; export { createVitePlugin } from './vite-adapter.js'; +export { + createDefaultWslMirroredRenderMode, + createDefaultWslNatRenderMode, + createWslPathTransformer, + getWslHostIp, +} from './wsl.js'; /** @hidden */ export type PublicationManifest = _PublicationManifest; diff --git a/src/wsl.ts b/src/wsl.ts new file mode 100644 index 00000000..3612e292 --- /dev/null +++ b/src/wsl.ts @@ -0,0 +1,214 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import upath from 'upath'; + +/** + * Helpers for the case where vivliostyle-cli runs on Windows-native Node.js + * but the docker daemon is upstream moby inside a WSL distro (for example, + * apt-installed Docker Engine on Ubuntu, bridged to Windows by WinSocat). + * + * Plain `renderMode: 'docker'` does not work in that setup for two reasons: + * + * 1. `host.docker.internal:host-gateway` resolves to the WSL VM's docker0, + * not the Windows host where Vite is running. Override + * `renderMode.hostGateway` with an IP that reaches the Windows host. + * 2. The bind-mount sources are absolute Windows paths (`C:\Users\...`), + * which upstream dockerd cannot parse. Translate them to drvfs + * automount form (`/mnt/c/Users/...`) via `renderMode.pathTransformer`. + * + * `createDefaultWslNatRenderMode()` and `createDefaultWslMirroredRenderMode()` + * return the settings that work for each WSL networking mode. Spread the + * result into a `renderMode` literal at the use site: + * + * ```ts + * import { createDefaultWslNatRenderMode } from '@vivliostyle/cli'; + * export default { + * output: [{ + * path: 'output.pdf', + * renderMode: process.platform === 'win32' + * ? { mode: 'docker', ...createDefaultWslNatRenderMode() } + * : 'docker', + * }], + * }; + * ``` + * + * You also need to open the host-side firewall once. The commands below + * persist across reboots (`New-NetFirewallRule` writes to `PersistentStore` + * by default; `Set-NetFirewallHyperVVMSetting` updates both + * `PersistentStore` and `ActiveStore`). Pass `-PolicyStore ActiveStore` + * to limit the change to the current session. + * + * --- NAT mode (`networkingMode=nat`, the WSL default) --- + * Use `createDefaultWslNatRenderMode()`. The WSL VM has its own subnet; its + * eth0 default gateway points at the Windows host, and `getWslHostIp()` + * resolves that IP at config-evaluation time. + * + * Allow inbound on the `vEthernet (WSL)` interface from an elevated + * PowerShell: + * + * ```powershell + * New-NetFirewallRule -DisplayName "Vivliostyle dev server (WSL)" ` + * -Direction Inbound ` + * -InterfaceAlias "vEthernet (WSL (Hyper-V firewall))" ` + * -Protocol TCP -Action Allow + * ``` + * + * The exact interface alias varies by Windows build; check it with + * `Get-NetIPAddress -AddressFamily IPv4`. + * + * --- Mirrored mode (`networkingMode=mirrored`) --- + * Use `createDefaultWslMirroredRenderMode()`. The WSL VM shares Windows network + * interfaces, so the WSL TCP stack intercepts the shared IPs and a + * default-bridged container has no IP it can use to reach Windows. The + * preset works around this by putting the container in the WSL VM netns + * via `--network=host` and reaching Windows over the localhost forwarder. + * + * Mirrored Windows-to-WSL traffic goes through the Hyper-V firewall, which + * blocks inbound by default. From an elevated PowerShell: + * + * ```powershell + * Set-NetFirewallHyperVVMSetting ` + * -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' ` + * -DefaultInboundAction Allow + * ``` + * + * `{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}` is the WSL VM identifier + * (`c_wslFirewallVmCreatorId` in microsoft/WSL). + */ + +export interface WslPathTransformerOptions { + /** + * The WSL automount root directory — i.e. the value of `automount.root` + * in the target distro's `/etc/wsl.conf`. `/mnt/` (the WSL default, used + * by Ubuntu and every other distro out of the box) means `C:` is mounted + * at `/mnt/c/`. Set this if the target distro has changed `automount.root` + * (e.g. `root = /` mounts `C:` at `/c/`) or if you have a custom value + * like `/windir/`. Trailing slash is optional. + * + * Note: `automount.root` is a WSL-level (DrvFs) setting, not a distro + * convention; the default is identical across Ubuntu, Debian, Alpine, + * Arch, openSUSE, etc. + */ + automountRoot?: string; +} + +/** + * Build a `renderMode.pathTransformer` that translates Windows drive-letter + * absolute paths to their WSL drvfs automount counterpart (default + * `/mnt//...`). Useful when the docker daemon is upstream moby + * running inside a WSL distro. + * + * Example: + * ```ts + * renderMode: { + * mode: 'docker', + * pathTransformer: createWslPathTransformer(), + * // ... + * } + * ``` + * + * Contract of the returned transformer: + * The input is expected to be an absolute path produced by `upath.resolve()` + * (the canonical resolver used in `src/config/resolve.ts` for `workspaceDir`, + * `target.path`, etc.). Under that contract the input is one of: + * - POSIX absolute (`/foo/bar`) on Linux/macOS hosts: passed through + * - Drive-letter + forward slash (`C:/Users/foo`) on Windows hosts: translated + * + * Drive-letter + backslash (`C:\Users\foo`) is handled defensively for paths + * that bypass `upath`. Anything else (relative paths, UNC `\\server\share\...`, + * empty input) violates the contract and throws. + * + * Pass `{ automountRoot }` if the target WSL distro has changed + * `automount.root` in `/etc/wsl.conf` (see {@link WslPathTransformerOptions}). + * Override does not apply to POSIX inputs; those pass through unchanged. + */ +export function createWslPathTransformer({ + automountRoot = '/mnt/', +}: WslPathTransformerOptions = {}): (hostPath: string) => string { + const base = automountRoot.endsWith('/') + ? automountRoot + : `${automountRoot}/`; + return (hostPath) => { + const { root } = path.win32.parse(hostPath); + + if (root === '/') return hostPath; + + if (root.length === 3 && root[1] === ':') { + return `${base}${root[0].toLowerCase()}/${upath.toUnix(hostPath.slice(root.length))}`; + } + + throw new Error( + `createWslPathTransformer: expected absolute path from upath.resolve(), ` + + `got ${JSON.stringify(hostPath)} (parsed root: ${JSON.stringify(root)}). ` + + `UNC, relative, and non-standard paths are out of scope; supply a custom ` + + `\`renderMode.pathTransformer\` to handle them.`, + ); + }; +} + +/** + * Returns the IP at which the Windows host is reachable from inside WSL + * (the default gateway of WSL's eth0). Useful as `renderMode.hostGateway` + * for the NAT networking mode (the WSL default). + * + * Windows host only. Caller is responsible for gating on `process.platform`. + */ +export function getWslHostIp(): string { + const out = execFileSync('wsl', ['--', 'ip', 'route', 'show', 'default'], { + encoding: 'utf8', + }); + const m = /default via (\S+)/.exec(out); + if (!m) { + throw new Error( + `getWslHostIp: failed to parse WSL default gateway from: ${out.trim()}`, + ); + } + return m[1]; +} + +/** + * Build the conventional default `renderMode` fields (without `mode`) for + * the WSL hybrid + NAT networking case. Spread into a `renderMode` literal: + * + * ```ts + * renderMode: { mode: 'docker', ...createDefaultWslNatRenderMode() } + * ``` + * + * `options` is forwarded to {@link createWslPathTransformer}; pass + * `{ automountRoot }` if the target WSL distro has changed `automount.root` + * in `/etc/wsl.conf`. + * + * It's a factory so `getWslHostIp()` runs at the call site; the WSL default + * gateway can change across VM restarts. + */ +export function createDefaultWslNatRenderMode( + options: WslPathTransformerOptions = {}, +) { + return { + hostGateway: getWslHostIp(), + pathTransformer: createWslPathTransformer(options), + }; +} + +/** + * Build the conventional default `renderMode` fields (without `mode`) for + * the WSL hybrid + mirrored networking case. Spread into a `renderMode` + * literal: + * + * ```ts + * renderMode: { mode: 'docker', ...createDefaultWslMirroredRenderMode() } + * ``` + * + * `options` is forwarded to {@link createWslPathTransformer}; pass + * `{ automountRoot }` if the target WSL distro has changed `automount.root` + * in `/etc/wsl.conf`. + */ +export function createDefaultWslMirroredRenderMode( + options: WslPathTransformerOptions = {}, +) { + return { + hostGateway: '127.0.0.1' as const, + pathTransformer: createWslPathTransformer(options), + extraRunArgs: ['--network=host'] as const, + }; +} diff --git a/tests/__snapshots__/config.test.ts.snap b/tests/__snapshots__/config.test.ts.snap index 41da2217..e33c9ae4 100644 --- a/tests/__snapshots__/config.test.ts.snap +++ b/tests/__snapshots__/config.test.ts.snap @@ -129,7 +129,9 @@ Object { "path": "__WORKSPACE__/tests/fixtures/config/yuno.pdf", "preflight": "press-ready", "preflightOption": Array [], - "renderMode": "local", + "renderMode": Object { + "mode": "local", + }, "replaceImage": Array [], }, Object { @@ -338,7 +340,9 @@ Object { "path": "__WORKSPACE__/tests/fixtures/config/output1.pdf", "preflight": "press-ready", "preflightOption": Array [], - "renderMode": "local", + "renderMode": Object { + "mode": "local", + }, "replaceImage": Array [], }, Object { @@ -347,7 +351,9 @@ Object { "path": "__WORKSPACE__/tests/fixtures/config/output2.pdf", "preflight": "press-ready", "preflightOption": Array [], - "renderMode": "local", + "renderMode": Object { + "mode": "local", + }, "replaceImage": Array [], }, ], diff --git a/tests/container.test.ts b/tests/container.test.ts new file mode 100644 index 00000000..be9ad9b9 --- /dev/null +++ b/tests/container.test.ts @@ -0,0 +1,356 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PdfOutput, ResolvedTaskConfig } from '../src/config/resolve.js'; +import type { ParsedVivliostyleInlineConfig } from '../src/config/schema.js'; + +const tinyexecMock = vi.hoisted(() => { + const x = vi.fn(async function* docker() { + // no output; tinyexec proc is async-iterable + }); + return { x }; +}); +vi.mock('tinyexec', () => tinyexecMock); + +const commandExistsMock = vi.hoisted(() => ({ + default: vi.fn().mockResolvedValue(true), +})); +vi.mock('command-exists', () => commandExistsMock); + +const utilExecMock = vi.hoisted(() => + vi.fn().mockResolvedValue({ stdout: '24.0.0', stderr: '' }), +); +vi.mock('../src/util.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, exec: utilExecMock }; +}); + +const { buildPDFWithContainer } = await import('../src/container.js'); + +const fabricateConfig = ( + overrides: Partial = {}, +): ResolvedTaskConfig => + ({ + rootUrl: 'http://localhost:13000', + base: '/vivliostyle/', + workspaceDir: '/workspace', + serverRootDir: '/workspace', + image: 'ghcr.io/vivliostyle/cli:test', + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'http://localhost:13000/vivliostyle/index.html', + webbookPath: undefined, + }, + ...overrides, + }) as unknown as ResolvedTaskConfig; + +const fabricateTarget = (overrides: Partial = {}): PdfOutput => ({ + format: 'pdf', + path: '/workspace/out/test.pdf', + renderMode: { + mode: 'docker', + hostGateway: undefined, + pathTransformer: undefined, + extraRunArgs: undefined, + }, + preflight: undefined, + preflightOption: [], + cmyk: false, + replaceImage: [], + ...overrides, +}); + +const lastDockerArgs = (): string[] => { + const calls = tinyexecMock.x.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const lastCall = calls[calls.length - 1] as unknown as [string, string[]]; + expect(lastCall[0]).toBe('docker'); + return lastCall[1]; +}; + +const envFromArgs = (args: string[]): Record => { + const env: Record = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '-e' && i + 1 < args.length) { + const [k, ...rest] = args[i + 1].split('='); + env[k] = rest.join('='); + } + } + return env; +}; + +const volumesFromArgs = ( + args: string[], +): { host: string; container: string }[] => { + const mounts: { host: string; container: string }[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '-v' && i + 1 < args.length) { + const [host, container] = args[i + 1].split(':'); + mounts.push({ host, container }); + } + } + return mounts; +}; + +beforeEach(() => { + tinyexecMock.x.mockClear(); + utilExecMock.mockClear(); + commandExistsMock.default.mockClear(); +}); + +describe('buildPDFWithContainer: HTTP source URL (server-startup path)', () => { + it('passes --add-host=host.docker.internal:host-gateway so the alias resolves on raw Linux dockerd (incl. WSL)', async () => { + await buildPDFWithContainer({ + target: fabricateTarget(), + config: fabricateConfig(), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const idx = args.indexOf('--add-host'); + expect(idx).toBeGreaterThan(-1); + expect(args[idx + 1]).toBe('host.docker.internal:host-gateway'); + }); + + it('overrides --add-host gateway when renderMode.hostGateway is set', async () => { + await buildPDFWithContainer({ + target: fabricateTarget({ + renderMode: { + mode: 'docker', + hostGateway: '172.21.112.1', + pathTransformer: undefined, + extraRunArgs: undefined, + }, + }), + config: fabricateConfig(), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const idx = args.indexOf('--add-host'); + expect(args[idx + 1]).toBe('host.docker.internal:172.21.112.1'); + }); + + it('passes renderMode.extraRunArgs through verbatim before the image', async () => { + await buildPDFWithContainer({ + target: fabricateTarget({ + renderMode: { + mode: 'docker', + hostGateway: '127.0.0.1', + pathTransformer: undefined, + extraRunArgs: ['--network=host', '--gpus=all'], + }, + }), + config: fabricateConfig(), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const networkIdx = args.indexOf('--network=host'); + const gpusIdx = args.indexOf('--gpus=all'); + const imageIdx = args.indexOf('ghcr.io/vivliostyle/cli:test'); + expect(networkIdx).toBeGreaterThan(-1); + expect(gpusIdx).toBe(networkIdx + 1); + expect(imageIdx).toBeGreaterThan(gpusIdx); + }); + + it('applies renderMode.pathTransformer to the host side of -v bind mounts only', async () => { + await buildPDFWithContainer({ + target: fabricateTarget({ + path: '/workspace/out/test.pdf', + renderMode: { + mode: 'docker', + hostGateway: undefined, + pathTransformer: (p) => + p.startsWith('/workspace') ? `/mnt/c${p}` : p, + extraRunArgs: undefined, + }, + }), + config: fabricateConfig({ serverRootDir: '/workspace' }), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const mounts = volumesFromArgs(args); + expect(mounts.map((m) => m.host)).toEqual( + expect.arrayContaining(['/mnt/c/workspace']), + ); + // container side is unchanged + const workspaceMount = mounts.find((m) => m.host === '/mnt/c/workspace'); + expect(workspaceMount?.container).toBe('/data/workspace'); + }); + + // Smoke test for the WSL+Win hybrid wiring: a single renderMode object + // form should produce both --add-host pointing at the Windows host (not + // the docker0 gateway, which here is the WSL VM) and -v paths translated + // to /mnt//. + it('produces WSL+Win hybrid-ready args when renderMode.hostGateway and pathTransformer are both set', async () => { + const { createWslPathTransformer } = await import('../src/wsl.js'); + await buildPDFWithContainer({ + target: fabricateTarget({ + path: 'C:/Users/me/work/out/test.pdf', + renderMode: { + mode: 'docker', + hostGateway: '172.21.112.1', + pathTransformer: createWslPathTransformer(), + extraRunArgs: undefined, + }, + }), + config: fabricateConfig({ serverRootDir: 'C:/Users/me/work' }), + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + + // host.docker.internal must point at the Windows host IP, not host-gateway + const idx = args.indexOf('--add-host'); + expect(args[idx + 1]).toBe('host.docker.internal:172.21.112.1'); + + // -v host paths must be /mnt/c/... (parsable by upstream dockerd in WSL), + // while container paths stay in '/data' namespace. + // The output dir (work/out) is de-duplicated under serverRootDir (work) + // by collectVolumeArgs, so a single mount covers both. + const mounts = volumesFromArgs(args); + expect(mounts).toEqual([ + { + host: '/mnt/c/Users/me/work', + container: '/data/Users/me/work', + }, + ]); + }); + + it('rewrites the entry hostname to host.docker.internal so the in-container CLI can reach the host Vite', async () => { + const config = fabricateConfig({ + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'http://localhost:13000/vivliostyle/index.html', + webbookPath: undefined, + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget(), + config, + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + expect(bypassed.input).toEqual({ + format: 'webbook', + entry: 'http://host.docker.internal:13000/vivliostyle/index.html', + }); + expect(bypassed.host).toBe('host.docker.internal'); + }); + + it('translates the output PDF path to the container path and mounts the output directory', async () => { + const config = fabricateConfig(); + await buildPDFWithContainer({ + target: fabricateTarget({ path: '/elsewhere/dist/out.pdf' }), + config, + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + expect(bypassed.output).toEqual([ + expect.objectContaining({ + path: '/data/elsewhere/dist/out.pdf', + }), + ]); + expect(volumesFromArgs(args)).toEqual( + expect.arrayContaining([ + { + host: '/workspace', + container: '/data/workspace', + }, + { + host: '/elsewhere/dist', + container: '/data/elsewhere/dist', + }, + ]), + ); + }); + + it('passes through external (non-host-Vite) HTTPS entries without hostname rewriting', async () => { + const config = fabricateConfig({ + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'https://example.com/book/index.html', + webbookPath: undefined, + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget(), + config, + inlineConfig: {} as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + expect(bypassed.input.entry).toBe('https://example.com/book/index.html'); + }); +}); + +// These tests document the *current* behavior of the file:// + Docker +// combination. They are intentionally written to lock in observations rather +// than to assert that the path is correct; see notes.md for the design +// discussion. If/when the design changes (e.g. "render-mode docker requires +// HTTP serve" is made an explicit requirement), these tests should be updated +// or replaced with an explicit error-path assertion. +describe('buildPDFWithContainer: file:// source URL (disableServerStartup path)', () => { + it('passes file:// entry URLs through unchanged, without toContainerPath translation', async () => { + const config = fabricateConfig({ + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'file:///abs/manuscript/index.html', + webbookPath: '/abs/manuscript/index.html', + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget(), + config, + inlineConfig: { + disableServerStartup: true, + } as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const bypassed = JSON.parse(envFromArgs(args).VS_CLI_BUILD_PDF_OPTIONS); + // The host-side path leaks into the container as-is. Inside the container + // the file is served at /data/abs/manuscript/index.html (toContainerPath + // mapping), so this URL will not resolve unless the in-container CLI + // happens to translate it, which it currently does not. + expect(bypassed.input).toEqual({ + format: 'webbook', + entry: 'file:///abs/manuscript/index.html', + }); + }); + + it('does not auto-mount the directory containing a file:// entry; only serverRootDir and the output dir are mounted', async () => { + const config = fabricateConfig({ + serverRootDir: '/workspace', + viewerInput: { + type: 'webbook', + webbookEntryUrl: 'file:///elsewhere/manuscript/index.html', + webbookPath: '/elsewhere/manuscript/index.html', + }, + }); + await buildPDFWithContainer({ + target: fabricateTarget({ path: '/workspace/out/test.pdf' }), + config, + inlineConfig: { + disableServerStartup: true, + } as ParsedVivliostyleInlineConfig, + }); + + const args = lastDockerArgs(); + const mounts = volumesFromArgs(args); + // /workspace/out is de-duplicated because /workspace already covers it + // (collectVolumeArgs drops paths whose parent is also a mount). + expect(mounts.map((m) => m.host)).toEqual(['/workspace']); + // The manuscript's directory is NOT mounted; even if the in-container + // CLI translated the file:// URL to its container path, the file would + // not exist there. + expect( + mounts.find((m) => m.host === '/elsewhere/manuscript'), + ).toBeUndefined(); + }); +}); diff --git a/tests/fixtures/render-on-docker/manuscript.md b/tests/fixtures/render-on-docker/manuscript.md new file mode 100644 index 00000000..d453f1f7 --- /dev/null +++ b/tests/fixtures/render-on-docker/manuscript.md @@ -0,0 +1,3 @@ +# Demonstration of Render mode + +Compare the difference in results between different rendering environments! diff --git a/tests/render-on-docker.test.ts b/tests/render-on-docker.test.ts new file mode 100644 index 00000000..43066814 --- /dev/null +++ b/tests/render-on-docker.test.ts @@ -0,0 +1,159 @@ +import { execFileSync } from 'node:child_process'; +import { fileTypeFromFile } from 'file-type'; +import { describe, expect, it } from 'vitest'; +import { resolveFixture, runCommand } from './command-util.js'; + +const probe = (cmd: string, args: string[]): boolean => { + try { + execFileSync(cmd, args, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +}; + +const dockerAvailable = probe('docker', ['version']); + +// Only one image is consulted: VIVLIOSTYLE_TEST_IMAGE if set, otherwise the +// latest published image. The image must already exist locally; we do not +// pull on demand, so CI without a pre-pulled image skips this suite. +const candidateImage = + process.env.VIVLIOSTYLE_TEST_IMAGE || 'ghcr.io/vivliostyle/cli:latest'; + +const image = + dockerAvailable && probe('docker', ['image', 'inspect', candidateImage]) + ? candidateImage + : undefined; + +// On the WSL+Win hybrid (CLI on Windows, dockerd in WSL), the default docker +// invocation can't work: Windows paths are unparseable by upstream dockerd, +// and host.docker.internal lands on the WSL VM rather than the Windows host. +// The basic test is expected to fail there, so we run it under `it.fails`. +// The two hybrid tests below cover the working configurations. +const enableHybridNat = process.env.VIVLIOSTYLE_TEST_WSL_HYBRID_NAT === '1'; +const enableHybridMirrored = + process.env.VIVLIOSTYLE_TEST_WSL_HYBRID_MIRRORED === '1'; +const enableAnyHybrid = enableHybridNat || enableHybridMirrored; +const itBasic = enableAnyHybrid ? it.fails : it; + +describe.skipIf(!image)( + 'render-mode docker (mirrors examples/render-on-docker/)', + () => { + itBasic( + 'produces a valid PDF for a markdown manuscript via docker render', + async () => { + await runCommand( + [ + 'build', + '--render-mode', + 'docker', + '--image', + image!, + '-o', + '.vs-pdf/out.pdf', + 'manuscript.md', + ], + { cwd: resolveFixture('render-on-docker'), port: 23100 }, + ); + + const type = await fileTypeFromFile( + resolveFixture('render-on-docker/.vs-pdf/out.pdf'), + ); + expect(type?.mime).toEqual('application/pdf'); + }, + 240000, + ); + }, +); + +// Win + WSL hybrid, NAT mode (the WSL default). The container reaches the +// Windows host through the WSL eth0 default gateway. Opt-in via env var; the +// host also needs a Defender Firewall rule allowing inbound on the +// vEthernet (WSL) interface. +describe.skipIf(!image || !enableHybridNat)( + 'render-mode docker (Win + WSL hybrid, networkingMode=nat)', + () => { + it('renders via { mode: "docker", ...createDefaultWslNatRenderMode() }', async () => { + const { createDefaultWslNatRenderMode } = await import('../src/wsl.js'); + await runCommand( + [ + 'build', + '--image', + image!, + '-o', + '.vs-pdf/out-wsl-nat.pdf', + 'manuscript.md', + ], + { + cwd: resolveFixture('render-on-docker'), + port: 23101, + config: { + entry: 'manuscript.md', + output: [ + { + path: '.vs-pdf/out-wsl-nat.pdf', + renderMode: { + mode: 'docker', + ...createDefaultWslNatRenderMode(), + }, + }, + ], + }, + }, + ); + + const type = await fileTypeFromFile( + resolveFixture('render-on-docker/.vs-pdf/out-wsl-nat.pdf'), + ); + expect(type?.mime).toEqual('application/pdf'); + }, 240000); + }, +); + +// Win + WSL hybrid, mirrored mode. The WSL VM shares Windows network +// interfaces, so the WSL TCP stack intercepts the shared IPs and a +// default-bridged container has no IP it can use to reach Windows. The fix +// is to put the container in the WSL VM netns (`--network=host`) and reach +// Windows over the localhost forwarder. Opt-in via env var; the host also +// needs the Hyper-V firewall set to allow inbound for the WSL VM. +describe.skipIf(!image || !enableHybridMirrored)( + 'render-mode docker (Win + WSL hybrid, networkingMode=mirrored)', + () => { + it('renders via { mode: "docker", ...createDefaultWslMirroredRenderMode() }', async () => { + const { createDefaultWslMirroredRenderMode } = await import( + '../src/wsl.js' + ); + await runCommand( + [ + 'build', + '--image', + image!, + '-o', + '.vs-pdf/out-wsl-mirrored.pdf', + 'manuscript.md', + ], + { + cwd: resolveFixture('render-on-docker'), + port: 23102, + config: { + entry: 'manuscript.md', + output: [ + { + path: '.vs-pdf/out-wsl-mirrored.pdf', + renderMode: { + mode: 'docker', + ...createDefaultWslMirroredRenderMode(), + }, + }, + ], + }, + }, + ); + + const type = await fileTypeFromFile( + resolveFixture('render-on-docker/.vs-pdf/out-wsl-mirrored.pdf'), + ); + expect(type?.mime).toEqual('application/pdf'); + }, 240000); + }, +); diff --git a/tests/wsl.test.ts b/tests/wsl.test.ts new file mode 100644 index 00000000..9b0505fe --- /dev/null +++ b/tests/wsl.test.ts @@ -0,0 +1,101 @@ +import { execFileSync } from 'node:child_process'; +import { describe, expect, it, vi } from 'vitest'; +import { createWslPathTransformer, getWslHostIp } from '../src/wsl.js'; + +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })); + +describe('createDefaultWslNatRenderMode', () => { + it('returns hostGateway from getWslHostIp() and a default-rooted path transformer', async () => { + vi.mocked(execFileSync).mockReturnValueOnce( + 'default via 172.21.112.1 dev eth0 proto kernel\n', + ); + const { createDefaultWslNatRenderMode } = await import('../src/wsl.js'); + const result = createDefaultWslNatRenderMode(); + expect(result.hostGateway).toBe('172.21.112.1'); + expect(result.pathTransformer('C:/Users/foo')).toBe('/mnt/c/Users/foo'); + }); + + it('threads automountRoot through to the returned pathTransformer', async () => { + vi.mocked(execFileSync).mockReturnValueOnce( + 'default via 172.21.112.1 dev eth0 proto kernel\n', + ); + const { createDefaultWslNatRenderMode } = await import('../src/wsl.js'); + const result = createDefaultWslNatRenderMode({ automountRoot: '/' }); + expect(result.pathTransformer('C:/Users/foo')).toBe('/c/Users/foo'); + }); +}); + +describe('createDefaultWslMirroredRenderMode', () => { + it('returns hostGateway = 127.0.0.1, a default-rooted path transformer, and extraRunArgs = [--network=host]', async () => { + const { createDefaultWslMirroredRenderMode } = await import( + '../src/wsl.js' + ); + const result = createDefaultWslMirroredRenderMode(); + expect(result.hostGateway).toBe('127.0.0.1'); + expect(result.extraRunArgs).toEqual(['--network=host']); + expect(result.pathTransformer('C:/Users/foo')).toBe('/mnt/c/Users/foo'); + }); + + it('threads automountRoot through to the returned pathTransformer', async () => { + const { createDefaultWslMirroredRenderMode } = await import( + '../src/wsl.js' + ); + const result = createDefaultWslMirroredRenderMode({ + automountRoot: '/windir', + }); + expect(result.pathTransformer('C:/Users/foo')).toBe('/windir/c/Users/foo'); + }); +}); + +describe('createWslPathTransformer', () => { + it.each([ + ['C:\\Users\\foo', '/mnt/c/Users/foo'], + ['C:/Users/foo', '/mnt/c/Users/foo'], + ['d:\\bar\\baz', '/mnt/d/bar/baz'], + ['/posix/abs', '/posix/abs'], + ])('default-built transformer translates %s → %s', (input, expected) => { + expect(createWslPathTransformer()(input)).toBe(expected); + }); + + it.each([ + ['C:/Users/foo', '/', '/c/Users/foo'], + ['C:/Users/foo', '/windir', '/windir/c/Users/foo'], + ['C:/Users/foo', '/windir/', '/windir/c/Users/foo'], + ['D:\\bar\\baz', '/c', '/c/d/bar/baz'], + ])( + 'with automountRoot=%2$s, transformer translates %1$s → %3$s', + (input, automountRoot, expected) => { + expect(createWslPathTransformer({ automountRoot })(input)).toBe(expected); + }, + ); + + it('leaves POSIX absolute paths unchanged even when automountRoot is overridden', () => { + const transform = createWslPathTransformer({ automountRoot: '/windir' }); + expect(transform('/posix/abs')).toBe('/posix/abs'); + }); + + it.each([ + ['relative/path'], + ['./dot-relative'], + [''], + ['\\\\server\\share\\foo'], + ])('throws on out-of-spec input %j', (input) => { + expect(() => createWslPathTransformer()(input)).toThrow( + /createWslPathTransformer: expected absolute path from upath\.resolve/, + ); + }); +}); + +describe('getWslHostIp', () => { + it('extracts the IP from `default via X.X.X.X dev eth0`', () => { + vi.mocked(execFileSync).mockReturnValueOnce( + 'default via 172.21.112.1 dev eth0 proto kernel\n', + ); + expect(getWslHostIp()).toBe('172.21.112.1'); + }); + + it('throws when output is unparseable', () => { + vi.mocked(execFileSync).mockReturnValueOnce('garbage\n'); + expect(() => getWslHostIp()).toThrow(/failed to parse/); + }); +});