mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge branch 'release/v0.8.0' of github.com:nexu-io/open-design into release/v0.8.0
This commit is contained in:
commit
4f70893b18
10 changed files with 508 additions and 0 deletions
7
.github/workflows/release-beta.yml
vendored
7
.github/workflows/release-beta.yml
vendored
|
|
@ -41,6 +41,13 @@ env:
|
|||
# no events leave the user's machine.
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
|
||||
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
|
||||
# to PostHog after `next build` and before the .map files are stripped
|
||||
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
|
||||
# and the helper still strips .map to keep source out of the installer.
|
||||
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
|
||||
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
|
|||
7
.github/workflows/release-preview.yml
vendored
7
.github/workflows/release-preview.yml
vendored
|
|
@ -19,6 +19,13 @@ env:
|
|||
# leave the user's machine, /api/analytics/config returns enabled=false).
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
|
||||
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
|
||||
# to PostHog after `next build` and before the .map files are stripped
|
||||
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
|
||||
# and the helper still strips .map to keep source out of the installer.
|
||||
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
|
||||
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
|
|||
7
.github/workflows/release-stable.yml
vendored
7
.github/workflows/release-stable.yml
vendored
|
|
@ -32,6 +32,13 @@ env:
|
|||
# leave the user's machine, /api/analytics/config returns enabled=false).
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
# PostHog Error tracking sourcemap upload. Personal API key (phx_...) and
|
||||
# project ID let tools-pack's web-sourcemaps step ship browser sourcemaps
|
||||
# to PostHog after `next build` and before the .map files are stripped
|
||||
# from the packaged bundle. Missing in PR/fork builds → upload is skipped
|
||||
# and the helper still strips .map to keep source out of the installer.
|
||||
POSTHOG_CLI_API_KEY: ${{ secrets.POSTHOG_CLI_API_KEY }}
|
||||
POSTHOG_CLI_PROJECT_ID: ${{ vars.POSTHOG_CLI_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,11 @@ const nextConfig: NextConfig = {
|
|||
allowedDevOrigins: configuredAllowedDevHosts(),
|
||||
outputFileTracingRoot: WORKSPACE_ROOT,
|
||||
reactStrictMode: true,
|
||||
// Emit browser sourcemaps so packaged-runtime exceptions can be symbolicated
|
||||
// by PostHog. `tools/pack/src/web-sourcemaps.ts` runs after `next build`
|
||||
// to inject chunk IDs, upload to PostHog, and ALWAYS delete the .map files
|
||||
// before packaging so source never ships inside an installer.
|
||||
productionBrowserSourceMaps: true,
|
||||
turbopack: {
|
||||
root: WORKSPACE_ROOT,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -85,6 +85,32 @@ export type ToolPackConfig = {
|
|||
*/
|
||||
posthogKey?: string;
|
||||
posthogHost?: string;
|
||||
/**
|
||||
* Personal API key (`phx_...`) used by the @posthog/cli sourcemap helper to
|
||||
* upload browser sourcemaps to PostHog after `next build` and before the
|
||||
* web bundle is copied into the Electron package. Sourced from
|
||||
* `POSTHOG_CLI_API_KEY` (or the legacy `POSTHOG_PERSONAL_API_KEY` alias)
|
||||
* in CI; when missing (local packaging by a contributor, fork builds, PRs)
|
||||
* the helper still strips the .map files so source never leaks into the
|
||||
* shipped installer — it just skips the upload step.
|
||||
*/
|
||||
posthogCliApiKey?: string;
|
||||
/**
|
||||
* PostHog project ID (e.g. `420348` for the official Open Design project)
|
||||
* used by `@posthog/cli sourcemap upload`. Sourced from
|
||||
* `POSTHOG_CLI_PROJECT_ID` (or the alias `POSTHOG_PROJECT_ID`) in CI.
|
||||
* Required for upload to be attempted; missing → strip-only path.
|
||||
*/
|
||||
posthogCliProjectId?: string;
|
||||
/**
|
||||
* PostHog **management** host used by `@posthog/cli sourcemap upload`. This
|
||||
* is the regional app host (e.g. `https://us.posthog.com`) — distinct from
|
||||
* `posthogHost` above, which is the **ingest** host (`us.i.posthog.com`)
|
||||
* used by the runtime SDK and accepts `/capture/` traffic only. Sourced
|
||||
* from `POSTHOG_CLI_HOST`; when missing, the CLI defaults to the US Cloud
|
||||
* app host on its own, which is correct for the official project.
|
||||
*/
|
||||
posthogCliHost?: string;
|
||||
to: ToolPackBuildOutput;
|
||||
webOutputMode: ToolPackWebOutputMode;
|
||||
workspaceRoot: string;
|
||||
|
|
@ -151,6 +177,46 @@ function resolveToolPackPosthogHost(value: string | undefined): string | undefin
|
|||
return normalized.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveToolPackPosthogCliApiKey(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
// Personal API keys start with `phx_`. As with POSTHOG_KEY, third-party
|
||||
// PostHog deployments may use different prefixes; only flag obviously-wrong
|
||||
// values (whitespace, control chars) so a misconfigured CI secret doesn't
|
||||
// silently corrupt the upload step.
|
||||
if (/[\s\x00-\x1f]/.test(normalized)) {
|
||||
throw new Error(`POSTHOG_CLI_API_KEY contains whitespace or control chars`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveToolPackPosthogCliProjectId(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
if (!/^[0-9]+$/.test(normalized)) {
|
||||
throw new Error(`POSTHOG_CLI_PROJECT_ID must be a numeric project id: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveToolPackPosthogCliHost(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error(`POSTHOG_CLI_HOST must be an absolute URL: ${value}`);
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new Error(`POSTHOG_CLI_HOST must be http(s): ${value}`);
|
||||
}
|
||||
return normalized.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveToolPackTelemetryRelayUrl(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
|
|
@ -239,6 +305,13 @@ export function resolveToolPackConfig(
|
|||
telemetryRelayUrl: resolveToolPackTelemetryRelayUrl(process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL),
|
||||
posthogKey: resolveToolPackPosthogKey(process.env.POSTHOG_KEY),
|
||||
posthogHost: resolveToolPackPosthogHost(process.env.POSTHOG_HOST),
|
||||
posthogCliApiKey: resolveToolPackPosthogCliApiKey(
|
||||
process.env.POSTHOG_CLI_API_KEY ?? process.env.POSTHOG_PERSONAL_API_KEY,
|
||||
),
|
||||
posthogCliProjectId: resolveToolPackPosthogCliProjectId(
|
||||
process.env.POSTHOG_CLI_PROJECT_ID ?? process.env.POSTHOG_PROJECT_ID,
|
||||
),
|
||||
posthogCliHost: resolveToolPackPosthogCliHost(process.env.POSTHOG_CLI_HOST),
|
||||
to: resolveToolPackBuildOutput(platform, options.to),
|
||||
webOutputMode: resolveToolPackWebOutputMode(platform, process.env.OD_WEB_OUTPUT_MODE),
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
import type { ToolPackConfig } from "./config.js";
|
||||
import { copyBundledResourceTrees, linuxResources } from "./resources.js";
|
||||
import { electronBuilderVersionForAppVersion, readRuntimeAppVersion } from "./versions.js";
|
||||
import { processWebSourcemaps } from "./web-sourcemaps.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
|
|
@ -377,6 +378,10 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
|
|||
try {
|
||||
await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: "server" });
|
||||
await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]);
|
||||
// Inject chunk IDs + upload browser sourcemaps to PostHog, then strip
|
||||
// .map files before AppImage packaging. See
|
||||
// `tools/pack/src/web-sourcemaps.ts`.
|
||||
await processWebSourcemaps(config);
|
||||
} finally {
|
||||
if (previousWebNextEnv == null) {
|
||||
await rm(webNextEnvPath, { force: true });
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||
|
||||
import type { ToolPackCache } from "../cache.js";
|
||||
import type { ToolPackConfig } from "../config.js";
|
||||
import { processWebSourcemaps } from "../web-sourcemaps.js";
|
||||
import { ensureWorkspaceBuildArtifacts } from "../workspace-build.js";
|
||||
import { runPnpm } from "./commands.js";
|
||||
|
||||
|
|
@ -24,6 +25,10 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
|
|||
OD_WEB_OUTPUT_MODE: config.webOutputMode,
|
||||
});
|
||||
await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]);
|
||||
// Inject chunk IDs + upload browser sourcemaps to PostHog, then strip
|
||||
// .map files. Runs before any packaging step copies the web output into
|
||||
// the Electron resources so .map never ends up inside the .app bundle.
|
||||
await processWebSourcemaps(config);
|
||||
} finally {
|
||||
if (previousWebNextEnv == null) {
|
||||
await rm(webNextEnvPath, { force: true });
|
||||
|
|
|
|||
229
tools/pack/src/web-sourcemaps.ts
Normal file
229
tools/pack/src/web-sourcemaps.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// Browser-sourcemap post-build step for packaged builds.
|
||||
//
|
||||
// Why this exists
|
||||
// ---------------
|
||||
// `apps/web/next.config.ts` sets `productionBrowserSourceMaps: true`, so
|
||||
// every `next build` invoked from tools-pack also produces `.js.map` files
|
||||
// alongside the minified chunks. That gives us two requirements:
|
||||
//
|
||||
// 1. Send the maps to PostHog so the Error tracking page can symbolicate
|
||||
// stack frames (otherwise users see `fO / fz / s4` instead of real
|
||||
// function names + file:line).
|
||||
// 2. Make sure no `.map` ever ends up inside a shipped installer (`.dmg`,
|
||||
// `.nsis`, `.AppImage`). Sourcemaps publish the original TypeScript
|
||||
// source to anyone who can read the bundle, which is a security &
|
||||
// competitive-disclosure problem.
|
||||
//
|
||||
// `processWebSourcemaps` does both:
|
||||
//
|
||||
// - With `POSTHOG_CLI_API_KEY` + `POSTHOG_CLI_PROJECT_ID` (CI on
|
||||
// release-{beta,stable,preview}): run `@posthog/cli sourcemap inject`
|
||||
// to bake chunk IDs into the JS/map pair, then `sourcemap upload` to
|
||||
// ship the maps to PostHog. Best-effort — if upload fails (rate limit,
|
||||
// network blip), the strip step below still runs.
|
||||
//
|
||||
// - Without those env vars (local `pnpm tools-pack mac build` by a
|
||||
// contributor, fork PR builds): skip both inject and upload, just strip.
|
||||
//
|
||||
// Either way, the final step ALWAYS removes every `.map` under the
|
||||
// browser chunks directory. Stripping is a hard requirement; only the
|
||||
// upload is conditional.
|
||||
//
|
||||
// Scope
|
||||
// -----
|
||||
// Only the packaged (mac/win/linux Electron) path is covered here. The OSS
|
||||
// `od` CLI distribution path serves `apps/web/out/_next/static/chunks/`
|
||||
// directly and is not currently used by any release artifact; it can be
|
||||
// added later if the OSS audience reports symbolication needs.
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createPackageManagerInvocation } from "@open-design/platform";
|
||||
|
||||
import type { ToolPackConfig } from "./config.js";
|
||||
import { execFileAsync } from "./mac/commands.js";
|
||||
|
||||
const POSTHOG_CLI_VERSION = "0.7.11";
|
||||
const RELEASE_NAME = "open-design-web";
|
||||
|
||||
export interface WebSourcemapOptions {
|
||||
/**
|
||||
* Optional release version to associate with the uploaded chunks. Falls
|
||||
* back to `config.appVersion` when omitted; if neither is set the CLI
|
||||
* derives one from git, which is fine but less precise than passing a
|
||||
* real semver/nightly identifier from the release workflow.
|
||||
*/
|
||||
releaseVersion?: string;
|
||||
}
|
||||
|
||||
interface SourcemapCliEnv {
|
||||
apiKey: string;
|
||||
projectId: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
function resolveBrowserChunksDir(workspaceRoot: string): string {
|
||||
// Both `output: 'standalone'` (mac/win) and the implicit server output
|
||||
// (linux) write browser chunks to `.next/static`. Static-export mode
|
||||
// (`apps/web/out/_next/static`) is not used by any release artifact.
|
||||
return join(workspaceRoot, "apps", "web", ".next", "static");
|
||||
}
|
||||
|
||||
async function findMapFiles(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current == null) break;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
// Directory might not exist on this branch of the tree; skip silently.
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
||||
out.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function deleteMapFiles(dir: string): Promise<number> {
|
||||
const maps = await findMapFiles(dir);
|
||||
for (const mapPath of maps) {
|
||||
await rm(mapPath, { force: true });
|
||||
}
|
||||
return maps.length;
|
||||
}
|
||||
|
||||
function readUploadEnv(config: ToolPackConfig): SourcemapCliEnv | null {
|
||||
if (config.posthogCliApiKey == null || config.posthogCliApiKey.length === 0) return null;
|
||||
if (config.posthogCliProjectId == null || config.posthogCliProjectId.length === 0) return null;
|
||||
return {
|
||||
apiKey: config.posthogCliApiKey,
|
||||
projectId: config.posthogCliProjectId,
|
||||
// Deliberately uses `posthogCliHost` (management host, us.posthog.com)
|
||||
// rather than `posthogHost` (ingest host, us.i.posthog.com). When the
|
||||
// CLI host is unset the @posthog/cli defaults to the US app host on
|
||||
// its own, which is correct for the official project.
|
||||
host: config.posthogCliHost,
|
||||
};
|
||||
}
|
||||
|
||||
function log(line: string): void {
|
||||
process.stderr.write(`[web-sourcemaps] ${line}\n`);
|
||||
}
|
||||
|
||||
async function runPnpm(
|
||||
config: ToolPackConfig,
|
||||
args: string[],
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): Promise<void> {
|
||||
// `createPackageManagerInvocation` is the same primitive every platform's
|
||||
// local `runPnpm` helper goes through, so the linux containerized build
|
||||
// (which sets `OD_TOOLS_PACK_PNPM_BIN` to the standalone pnpm binary it
|
||||
// bootstrapped) picks up the right command here too.
|
||||
const invocation = createPackageManagerInvocation(args, process.env);
|
||||
await execFileAsync(invocation.command, invocation.args, {
|
||||
cwd: config.workspaceRoot,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
export async function processWebSourcemaps(
|
||||
config: ToolPackConfig,
|
||||
options: WebSourcemapOptions = {},
|
||||
): Promise<void> {
|
||||
const chunksDir = resolveBrowserChunksDir(config.workspaceRoot);
|
||||
if (!existsSync(chunksDir)) {
|
||||
log(`browser chunks dir not found at ${chunksDir}; skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const initialMaps = await findMapFiles(chunksDir);
|
||||
if (initialMaps.length === 0) {
|
||||
log(`no .map files under ${chunksDir}; nothing to do`);
|
||||
return;
|
||||
}
|
||||
log(`found ${initialMaps.length} .map file(s) under ${chunksDir}`);
|
||||
|
||||
const uploadEnv = readUploadEnv(config);
|
||||
const releaseVersion = options.releaseVersion ?? config.appVersion;
|
||||
|
||||
if (uploadEnv != null) {
|
||||
const cliEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
POSTHOG_CLI_API_KEY: uploadEnv.apiKey,
|
||||
POSTHOG_CLI_PROJECT_ID: uploadEnv.projectId,
|
||||
...(uploadEnv.host ? { POSTHOG_CLI_HOST: uploadEnv.host } : {}),
|
||||
};
|
||||
const releaseArgs = [
|
||||
"--release-name",
|
||||
RELEASE_NAME,
|
||||
...(releaseVersion ? ["--release-version", releaseVersion] : []),
|
||||
];
|
||||
// inject must succeed first so the chunk ID baked into .js matches the
|
||||
// .map's metadata; the resulting .js change is just a trailing
|
||||
// `//# chunkId=...` comment so it's safe to retry the build with the
|
||||
// same source if anything below fails.
|
||||
try {
|
||||
await runPnpm(
|
||||
config,
|
||||
[
|
||||
"dlx",
|
||||
`@posthog/cli@${POSTHOG_CLI_VERSION}`,
|
||||
"sourcemap",
|
||||
"inject",
|
||||
"--directory",
|
||||
chunksDir,
|
||||
...releaseArgs,
|
||||
],
|
||||
cliEnv,
|
||||
);
|
||||
} catch (error) {
|
||||
log(`inject failed: ${(error as Error).message}; continuing to strip`);
|
||||
}
|
||||
// upload is best-effort — `--no-fail` keeps non-zero exits inside the
|
||||
// CLI from killing the release. If this fails the user simply sees
|
||||
// unsymbolicated stacks in PostHog, which is no worse than today.
|
||||
try {
|
||||
const hostFlag = uploadEnv.host ? ["--host", uploadEnv.host] : [];
|
||||
await runPnpm(
|
||||
config,
|
||||
[
|
||||
"dlx",
|
||||
`@posthog/cli@${POSTHOG_CLI_VERSION}`,
|
||||
...hostFlag,
|
||||
"--no-fail",
|
||||
"sourcemap",
|
||||
"upload",
|
||||
"--directory",
|
||||
chunksDir,
|
||||
...releaseArgs,
|
||||
],
|
||||
cliEnv,
|
||||
);
|
||||
} catch (error) {
|
||||
log(`upload failed: ${(error as Error).message}; continuing to strip`);
|
||||
}
|
||||
} else {
|
||||
log("POSTHOG_CLI_API_KEY/POSTHOG_CLI_PROJECT_ID missing; skipping upload");
|
||||
}
|
||||
|
||||
// Hard requirement: never let a .map slip into the shipped installer.
|
||||
// This runs even if the CLI's own `--delete-after` succeeded — duplicate
|
||||
// delete is a no-op, and the explicit pass also catches files the CLI
|
||||
// skipped (anything that wasn't paired with a matching .js, or .map
|
||||
// files added by future tooling we haven't audited yet).
|
||||
const stripped = await deleteMapFiles(chunksDir);
|
||||
log(`stripped ${stripped} .map file(s) before packaging`);
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
shouldInstallInternalPackageForWinPrebundle,
|
||||
shouldUseWinStandalonePrebundle,
|
||||
} from "../win-prebundle.js";
|
||||
import { processWebSourcemaps } from "../web-sourcemaps.js";
|
||||
import { ensureWorkspaceBuildArtifacts } from "../workspace-build.js";
|
||||
import {
|
||||
ELECTRON_BUILDER_BUILD_DEPENDENCIES_FROM_SOURCE,
|
||||
|
|
@ -128,6 +129,10 @@ async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise<void> {
|
|||
try {
|
||||
await runPnpm(config, ["--filter", "@open-design/web", "build"], { OD_WEB_OUTPUT_MODE: config.webOutputMode });
|
||||
await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]);
|
||||
// Inject chunk IDs + upload browser sourcemaps to PostHog, then strip
|
||||
// .map files before any packaging step copies the web output into the
|
||||
// Electron resources. See `tools/pack/src/web-sourcemaps.ts`.
|
||||
await processWebSourcemaps(config);
|
||||
} finally {
|
||||
if (previousWebNextEnv == null) await rm(webNextEnvPath, { force: true });
|
||||
else await writeFile(webNextEnvPath, previousWebNextEnv, "utf8");
|
||||
|
|
|
|||
165
tools/pack/tests/web-sourcemaps.test.ts
Normal file
165
tools/pack/tests/web-sourcemaps.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { ToolPackConfig } from "../src/config.js";
|
||||
import { processWebSourcemaps } from "../src/web-sourcemaps.js";
|
||||
|
||||
/**
|
||||
* These tests cover the parts of `processWebSourcemaps` that don't shell out
|
||||
* to `@posthog/cli`:
|
||||
*
|
||||
* - missing chunks dir: returns silently
|
||||
* - no .map files: returns silently
|
||||
* - no credentials: ALWAYS strips .map files (the security guarantee)
|
||||
*
|
||||
* The upload-credentials-set path is not exercised here because it would have
|
||||
* to either reach PostHog (network in unit tests is forbidden) or mock out
|
||||
* `runPnpm`, which is intentionally an internal helper. The CLI happy-path
|
||||
* runs in the release workflows themselves; this suite focuses on the
|
||||
* strip-always invariant that must hold for both PR/fork builds and the rare
|
||||
* case where the upload step fails inside the release.
|
||||
*/
|
||||
|
||||
let tempRoot: string;
|
||||
const SAVED_API_KEY = process.env.POSTHOG_CLI_API_KEY;
|
||||
const SAVED_PROJECT_ID = process.env.POSTHOG_CLI_PROJECT_ID;
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value == null) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempRoot = await mkdtemp(join(tmpdir(), "od-web-sourcemaps-"));
|
||||
// Force the "no credentials" path so we test the strip-always invariant
|
||||
// without needing to mock @posthog/cli or hit the network.
|
||||
delete process.env.POSTHOG_CLI_API_KEY;
|
||||
delete process.env.POSTHOG_CLI_PROJECT_ID;
|
||||
delete process.env.POSTHOG_PERSONAL_API_KEY;
|
||||
delete process.env.POSTHOG_PROJECT_ID;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempRoot != null) {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
restoreEnv("POSTHOG_CLI_API_KEY", SAVED_API_KEY);
|
||||
restoreEnv("POSTHOG_CLI_PROJECT_ID", SAVED_PROJECT_ID);
|
||||
});
|
||||
|
||||
function fakeConfig(workspaceRoot: string): ToolPackConfig {
|
||||
return {
|
||||
appVersion: "0.0.0-test",
|
||||
containerized: false,
|
||||
electronBuilderCliPath: "/dev/null",
|
||||
electronDistPath: "/dev/null",
|
||||
electronVersion: "0.0.0",
|
||||
macCompression: "normal",
|
||||
namespace: "test",
|
||||
platform: "mac",
|
||||
portable: false,
|
||||
removeData: false,
|
||||
removeLogs: false,
|
||||
removeProductUserData: false,
|
||||
removeSidecars: false,
|
||||
roots: {
|
||||
output: {
|
||||
appBuilderRoot: join(workspaceRoot, "out", "builder"),
|
||||
namespaceRoot: join(workspaceRoot, "out", "ns"),
|
||||
platformRoot: join(workspaceRoot, "out", "mac"),
|
||||
root: join(workspaceRoot, "out"),
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: join(workspaceRoot, "runtime"),
|
||||
namespaceRoot: join(workspaceRoot, "runtime", "test"),
|
||||
},
|
||||
cacheRoot: join(workspaceRoot, "cache"),
|
||||
toolPackRoot: join(workspaceRoot, "tools-pack"),
|
||||
},
|
||||
signed: false,
|
||||
silent: true,
|
||||
to: "all",
|
||||
webOutputMode: "standalone",
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupChunksDir(rootDir: string, mapNames: string[]): Promise<string> {
|
||||
const chunksDir = join(rootDir, "apps", "web", ".next", "static");
|
||||
await mkdir(join(chunksDir, "chunks"), { recursive: true });
|
||||
// Always create a .js file paired with each .map so the layout matches what
|
||||
// Next.js actually emits — otherwise a future helper change that filters by
|
||||
// pairing would silently no-op the test.
|
||||
for (const name of mapNames) {
|
||||
const baseName = name.replace(/\.map$/, "");
|
||||
await writeFile(join(chunksDir, "chunks", baseName), "/* fake bundle */\n", "utf8");
|
||||
await writeFile(join(chunksDir, "chunks", name), '{"version":3,"sources":[]}\n', "utf8");
|
||||
}
|
||||
return chunksDir;
|
||||
}
|
||||
|
||||
describe("processWebSourcemaps", () => {
|
||||
it("returns silently when the browser chunks directory does not exist", async () => {
|
||||
const config = fakeConfig(tempRoot);
|
||||
await expect(processWebSourcemaps(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns silently when the chunks directory has no .map files", async () => {
|
||||
const chunksDir = await setupChunksDir(tempRoot, []);
|
||||
// Drop a non-map file so the dir is not empty but contains no sourcemaps.
|
||||
await writeFile(join(chunksDir, "chunks", "main.js"), "/* */", "utf8");
|
||||
const config = fakeConfig(tempRoot);
|
||||
await expect(processWebSourcemaps(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips every .map file when credentials are missing (strip-only path)", async () => {
|
||||
const chunksDir = await setupChunksDir(tempRoot, [
|
||||
"framework-abc.js.map",
|
||||
"main-app-def.js.map",
|
||||
"polyfills-ghi.js.map",
|
||||
]);
|
||||
const config = fakeConfig(tempRoot);
|
||||
|
||||
await processWebSourcemaps(config);
|
||||
|
||||
// Every .map gone, every .js preserved.
|
||||
await expect(
|
||||
readFile(join(chunksDir, "chunks", "framework-abc.js.map"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
readFile(join(chunksDir, "chunks", "main-app-def.js.map"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
readFile(join(chunksDir, "chunks", "polyfills-ghi.js.map"), "utf8"),
|
||||
).rejects.toThrow();
|
||||
const preservedJs = await readFile(
|
||||
join(chunksDir, "chunks", "framework-abc.js"),
|
||||
"utf8",
|
||||
);
|
||||
expect(preservedJs).toContain("fake bundle");
|
||||
});
|
||||
|
||||
it("strips .map files in nested subdirectories under .next/static", async () => {
|
||||
const chunksDir = await setupChunksDir(tempRoot, []);
|
||||
// Next.js puts some bundles under `.next/static/css` and `.next/static/media`
|
||||
// even though the JS chunks live in `.next/static/chunks`. The strip walker
|
||||
// must recurse — otherwise we'd leak CSS-source-style maps if Next ever
|
||||
// emits them under those paths.
|
||||
const nestedDir = join(chunksDir, "media");
|
||||
await mkdir(nestedDir, { recursive: true });
|
||||
await writeFile(join(nestedDir, "x.js"), "/* */", "utf8");
|
||||
await writeFile(join(nestedDir, "x.js.map"), "{}", "utf8");
|
||||
|
||||
const config = fakeConfig(tempRoot);
|
||||
await processWebSourcemaps(config);
|
||||
|
||||
await expect(readFile(join(nestedDir, "x.js.map"), "utf8")).rejects.toThrow();
|
||||
await expect(readFile(join(nestedDir, "x.js"), "utf8")).resolves.toContain("/*");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue