Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .configs/vitest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,37 @@ 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"],
exclude: ["node_modules"],
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
Expand Down
77 changes: 61 additions & 16 deletions devcheck.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @Last modified by: Nate Hyson <CLDMV> (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";
Expand All @@ -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 = !!(
Expand All @@ -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");
Comment thread
Shinrai marked this conversation as resolved.

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 <file>");
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);
}
Expand Down
6 changes: 1 addition & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Comment thread
Shinrai marked this conversation as resolved.
Expand Down
159 changes: 159 additions & 0 deletions tests/DevCheck.test.vitest.mjs
Original file line number Diff line number Diff line change
@@ -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 <CLDMV>
* @Email: <Shinrai@users.noreply.github.com>
* -----
* @Last modified by: Nate Hyson <CLDMV> (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/<scope>/<pkg> 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 <value>` (space form) but rejects `-C=<value>`, 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("");
});
});
Loading