diff --git a/.configs/vitest.config.mjs b/.configs/vitest.config.mjs index fd25d2e..b0bedf6 100644 --- a/.configs/vitest.config.mjs +++ b/.configs/vitest.config.mjs @@ -8,6 +8,24 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export default defineConfig({ root, + // The package-scoped dev condition that routes `@cldmv/holdmytask/main` to `src/` + // (see the `./main` export in package.json). Tests exercise and cover the SOURCE + // tree, so the resolver must add `holdmytask-dev`. This *replaces* vite's default + // conditions, so the usual ones are kept alongside it. `test.nodeOptions` below + // carries the same condition into forked test workers (for native imports of the + // package entry, e.g. CommonAliases importing index.mjs -> /main), so a bare local + // `npm test` resolves to src the same way CI does. Mirrors @cldmv/uuid. + resolve: { + conditions: ["holdmytask-dev", "module", "browser", "development|production"] + }, + ssr: { + // Vitest often routes node-environment resolution through the SSR pipeline. Keep + // `module` here alongside the non-SSR resolver so a dependency's `module`-keyed + // export resolves the same under Vitest's SSR pipeline as in a normal build. + resolve: { + conditions: ["holdmytask-dev", "module", "node", "development|production"] + } + }, test: { // Fleet-wide vitest test-file convention: `*.test.vitest.mjs`. include: ["tests/**/*.test.vitest.mjs"], @@ -15,6 +33,12 @@ export default defineConfig({ environment: "node", globals: true, testTimeout: 30000, + // Carry the dev condition into forked workers (native imports of the package + // entry, e.g. CommonAliases importing index.mjs -> /main). NODE_ENV is left + // alone: it does not select the conditional export (that's `--conditions`), and + // forcing a non-standard `NODE_ENV=holdmytask-dev` could confuse deps that key + // off the usual test/development/production values. + nodeOptions: ["--conditions=holdmytask-dev"], // "dot" keeps CI logs to one character per test file instead of a full // "RUN vX.Y.Z" + per-file pass/fail block for every file — vitest's // non-interactive fallback (no TTY to redraw) otherwise reprints that diff --git a/devcheck.mjs b/devcheck.mjs index 9303010..aef5cf2 100644 --- a/devcheck.mjs +++ b/devcheck.mjs @@ -8,7 +8,7 @@ * @Last modified by: Nate Hyson (Shinrai@users.noreply.github.com) * @Last modified time: 2025-11-21 14:51:16 -08:00 (1763765476) * ----- - * @Copyright: Copyright (c) 2013-2025 Catalyzed Motivation Inc. All rights reserved. + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. */ import { existsSync } from "node:fs"; @@ -18,7 +18,6 @@ import path from "node:path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const srcPath = path.join(__dirname, "src"); -// const distPath = path.join(__dirname, "dist"); // Detect if we're running in a CI environment const isCI = !!( @@ -32,29 +31,75 @@ const isCI = !!( process.env.TF_BUILD // Azure DevOps ); -if (existsSync(srcPath) && !isCI) { - // if (existsSync(srcPath) && !existsSync(distPath)) { - const nodeEnv = process.env.NODE_ENV?.toLowerCase(); - const hasNodeOptions = process.env.NODE_OPTIONS?.includes("--conditions=development"); +// Skip when installed as a dependency (a `node_modules` segment anywhere above this +// file - covers scoped `node_modules/@cldmv/holdmytask` and unscoped installs). The +// npm-published package ships neither `src/` nor this file, so this branch is already +// moot there; but a git/tarball install DOES include them, and without this guard +// devcheck would `process.exit(1)` inside a consumer's app. A "parent dir === +// node_modules" check would miss scoped packages (parent is the scope dir). +const isInstalledPackage = __dirname.split(path.sep).includes("node_modules"); - if (!nodeEnv || (!["dev", "development"].includes(nodeEnv) && !hasNodeOptions)) { +// Only meaningful in a source checkout. When `src/` is present the developer should be +// loading from it via the `holdmytask-dev` condition; if that condition isn't set they +// are silently running the built `dist/` copy instead, so warn - even after a build, +// since a built checkout has BOTH src/ and dist/ and the condition is the only thing +// that selects src/. +if (existsSync(srcPath) && !isCI && !isInstalledPackage) { + // The condition selects src/ (see the `./main` export in package.json). It can be + // supplied via NODE_OPTIONS (`NODE_OPTIONS=--conditions=holdmytask-dev`) OR directly + // on the node CLI (`node --conditions=holdmytask-dev` / `-C holdmytask-dev`), which + // lands in execArgv - this is how vitest passes it to workers - so scan both. Each + // `--conditions` occurrence is ONE literal condition value: Node does not split it on + // `,` or `|` (verified - `--conditions=holdmytask-dev,x` and + // `--conditions=holdmytask-dev|x` do NOT enable `holdmytask-dev`), and multiple + // conditions are passed as repeated flags. So collect each value whole and match + // EXACTLY - no substring, no splitting - so `not-holdmytask-dev`, `holdmytask-dev,x`, + // and `holdmytask-dev|production` all correctly fail to count. Namespaced (not the + // generic `development`) so a consuming app's own `--conditions=development` can't + // flip this package to a source tree it doesn't ship. NODE_ENV is deliberately NOT + // consulted: it does not affect which tree resolves. + const conditions = []; + const scan = (tokens) => { + for (let i = 0; i < tokens.length; i++) { + if (tokens[i] === "--conditions" || tokens[i] === "-C") { + // Space form (`--conditions x` / `-C x`): consume the following token as this + // flag's value and SKIP it, so a value that itself looks like a flag (e.g. the + // literal `--conditions=x`) isn't re-interpreted on the next iteration. + if (tokens[i + 1] !== undefined) { + conditions.push(tokens[i + 1]); + i++; + } + } else if (tokens[i].startsWith("--conditions=")) { + conditions.push(tokens[i].slice("--conditions=".length)); + } + // Note: `-C=x` is intentionally not handled - Node rejects it ("bad option"), + // so it can never appear in execArgv/NODE_OPTIONS. Valid forms are + // `--conditions=x`, `--conditions x`, and `-C x`. + } + }; + scan(process.execArgv); + scan((process.env.NODE_OPTIONS || "").split(/\s+/).filter(Boolean)); + const hasHoldMyTaskDev = conditions.includes("holdmytask-dev"); + + if (!hasHoldMyTaskDev) { console.error("❌ Development environment not properly configured!"); - console.error("📁 Source folder detected but NODE_ENV/NODE_OPTIONS not set for development."); + console.error("📁 Source folder detected but the 'holdmytask-dev' condition is not set,"); + console.error(" so imports resolve to dist/ by default (or fail if it isn't built) instead of src/."); console.error(""); - console.error("🔧 To fix this, run one of these commands:"); + console.error("🔧 To load from src/ for development, set the condition:"); console.error(" Windows (cmd):"); - console.error(" set NODE_ENV=development"); - console.error(" set NODE_OPTIONS=--conditions=development"); + console.error(" set NODE_OPTIONS=--conditions=holdmytask-dev"); console.error(""); console.error(" Windows (PowerShell):"); - console.error(" $env:NODE_ENV='development'"); - console.error(" $env:NODE_OPTIONS='--conditions=development'"); + console.error(" $env:NODE_OPTIONS='--conditions=holdmytask-dev'"); console.error(""); console.error(" Unix/Linux/macOS:"); - console.error(" export NODE_ENV=development"); - console.error(" export NODE_OPTIONS=--conditions=development"); + console.error(" export NODE_OPTIONS=--conditions=holdmytask-dev"); + console.error(""); + console.error(" ...or pass it directly: node --conditions=holdmytask-dev "); console.error(""); - console.error("💡 This ensures this module loads from src/ instead of dist/ for development."); + console.error("💡 'holdmytask-dev' is namespaced so it can't conflict with a consumer's"); + console.error(" own development conditions."); console.error("🚀 CI environments automatically skip this check."); process.exit(1); } diff --git a/package.json b/package.json index d2ed457..1e233f3 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,8 @@ "import": "./index.mjs", "require": "./index.cjs" }, - "./devcheck": { - "types": "./types/devcheck.d.mts", - "import": "./devcheck.mjs" - }, "./main": { - "development": { + "holdmytask-dev": { "types": "./types/src/hold-my-task.d.mts", "import": "./src/hold-my-task.mjs" }, diff --git a/tests/DevCheck.test.vitest.mjs b/tests/DevCheck.test.vitest.mjs new file mode 100644 index 0000000..e086a0d --- /dev/null +++ b/tests/DevCheck.test.vitest.mjs @@ -0,0 +1,159 @@ +/** + * @Project: @cldmv/holdmytask + * @Filename: /tests/DevCheck.test.vitest.mjs + * @Date: 2026-08-08T00:00:00-08:00 (1786233600) + * @Author: Nate Hyson + * @Email: + * ----- + * @Last modified by: Nate Hyson (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-08-08T00:00:00-08:00 (1786233600) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +import { test, expect, describe, beforeAll, afterAll } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, copyFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// devcheck.mjs resolves `src/` relative to its own file location and reads process.env +// / process.execArgv, so each case runs a COPY of it in a purpose-built fixture +// directory with a from-scratch env (only PATH), preventing the real CI environment +// this suite runs in from leaking `CI`/`GITHUB_ACTIONS`/`NODE_OPTIONS` into the +// subprocess. +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const devcheckSrc = path.join(repoRoot, "devcheck.mjs"); + +let tmpRoot; +let counter = 0; + +beforeAll(() => { + tmpRoot = mkdtempSync(path.join(tmpdir(), "holdmytask-devcheck-")); +}); + +afterAll(() => { + if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true }); +}); + +// Materialize a fixture dir with a copy of devcheck.mjs plus optional src/ and dist/, +// optionally nested under node_modules// to simulate an installed package. +function makeFixture({ src = true, dist = false, installed = false } = {}) { + const base = path.join(tmpRoot, `f${counter++}`); + const pkgDir = installed ? path.join(base, "node_modules", "@cldmv", "holdmytask") : path.join(base, "holdmytask"); + mkdirSync(pkgDir, { recursive: true }); + if (src) mkdirSync(path.join(pkgDir, "src"), { recursive: true }); + if (dist) mkdirSync(path.join(pkgDir, "dist"), { recursive: true }); + copyFileSync(devcheckSrc, path.join(pkgDir, "devcheck.mjs")); + return path.join(pkgDir, "devcheck.mjs"); +} + +// nodeArgs are passed on the node CLI (i.e. become process.execArgv); env is a +// from-scratch environment (only PATH plus whatever is given). +function runDevcheck(fixtureOpts, { env = {}, nodeArgs = [] } = {}) { + const devcheck = makeFixture(fixtureOpts); + const result = spawnSync(process.execPath, [...nodeArgs, devcheck], { + env: { PATH: process.env.PATH, ...env }, + encoding: "utf8" + }); + return { status: result.status, stderr: result.stderr || "" }; +} + +describe("devcheck", () => { + test("nags in a source checkout when the holdmytask-dev condition is not set", () => { + const { status, stderr } = runDevcheck({ src: true }); + expect(status).toBe(1); + expect(stderr).toContain("Development environment not properly configured"); + expect(stderr).toContain("--conditions=holdmytask-dev"); + }); + + test("stays silent when the condition is set via NODE_OPTIONS", () => { + const { status, stderr } = runDevcheck({ src: true }, { env: { NODE_OPTIONS: "--conditions=holdmytask-dev" } }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); + + test("stays silent when the condition is passed on the node CLI (execArgv, = form)", () => { + // vitest passes --conditions to workers this way, so devcheck must detect it here too. + const { status, stderr } = runDevcheck({ src: true }, { nodeArgs: ["--conditions=holdmytask-dev"] }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); + + test("stays silent when the condition is passed space-separated (--conditions holdmytask-dev)", () => { + const { status, stderr } = runDevcheck({ src: true }, { nodeArgs: ["--conditions", "holdmytask-dev"] }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); + + test("stays silent when holdmytask-dev is one of several repeated --conditions flags", () => { + const { status } = runDevcheck({ src: true }, { nodeArgs: ["--conditions=foo", "--conditions=holdmytask-dev"] }); + expect(status).toBe(0); + }); + + test("stays silent via the -C short flag (Node's alias for --conditions)", () => { + // Node accepts `-C ` (space form) but rejects `-C=`, so only the + // space form is a real input to detect. + const { status, stderr } = runDevcheck({ src: true }, { nodeArgs: ["-C", "holdmytask-dev"] }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); + + test("NODE_ENV=development alone does NOT silence it (only the condition selects src/)", () => { + // Keying off NODE_ENV would be a false negative: dev env set but no condition means + // the package is still resolving to dist/, which is exactly what should be flagged. + const { status } = runDevcheck({ src: true }, { env: { NODE_ENV: "development" } }); + expect(status).toBe(1); + }); + + test("STILL nags when dist/ has been built but the condition is not set", () => { + // A build must NOT silence the check: with src/ present the developer should be on + // src/ via the condition, not the stale dist/. + const { status } = runDevcheck({ src: true, dist: true }); + expect(status).toBe(1); + }); + + test("does NOT accept a generic development condition (namespacing)", () => { + const { status } = runDevcheck({ src: true }, { nodeArgs: ["--conditions=development"] }); + expect(status).toBe(1); + }); + + test("does NOT match a condition that merely contains 'holdmytask-dev' as a substring", () => { + // Exact-value match, not substring: --conditions=not-holdmytask-dev must NOT silence it. + const { status } = runDevcheck({ src: true }, { nodeArgs: ["--conditions=not-holdmytask-dev"] }); + expect(status).toBe(1); + }); + + test("does NOT treat a comma-joined value as separate conditions", () => { + // Node does not split --conditions on `,`: `foo,holdmytask-dev,bar` is one literal + // condition, so holdmytask-dev is NOT enabled and devcheck must still nag. + const { status } = runDevcheck({ src: true }, { nodeArgs: ["--conditions=foo,holdmytask-dev,bar"] }); + expect(status).toBe(1); + }); + + test("does NOT treat a pipe-joined value as separate conditions", () => { + // Likewise Node does not split on `|` (it's a valid condition character, e.g. + // Vite's `development|production`): `holdmytask-dev|production` does not enable it. + const { status } = runDevcheck({ src: true }, { nodeArgs: ["--conditions=holdmytask-dev|production"] }); + expect(status).toBe(1); + }); + + test("skips in CI", () => { + const { status, stderr } = runDevcheck({ src: true }, { env: { CI: "true" } }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); + + test("skips when installed as a scoped dependency (node_modules/@cldmv/holdmytask)", () => { + const { status, stderr } = runDevcheck({ src: true, installed: true }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); + + test("does nothing when there is no src/ (published dist-only layout)", () => { + const { status, stderr } = runDevcheck({ src: false, dist: true }); + expect(status).toBe(0); + expect(stderr).toBe(""); + }); +});