inspector/clients/web/src/test/integration/server/vite-base-config.test.ts
cliffhall 09e1564672 fix(auth): refuse on a stuck lock, capture identity at the mkdir seam (#2082)
Copilot review round 6 — five findings (two inline, three suppressed), all
valid.

**A stale lock that cannot be cleared caused unlocked writes.** `acquireLock`
does not only *create* directories: on finding a stale one it removes it and
retries, and that removal can fail — `ENOTEMPTY` for a lock with anything
inside it, `EACCES`/`EROFS` for one we may not touch. Those surface as
ordinary non-`ELOCKED` errors, which the probe read as "locks do not work
here" and degraded on. So every Inspector on the box quietly bypassed the
*same* stuck lock and raced its writes — while the release-failure message
was telling the operator saves would keep failing until they cleared it.

`isStuckOrHeld` now discriminates on **whether the lock directory exists**
rather than on an errno taxonomy: if it is there, something holds it and we
refuse; if it is not, we genuinely could not create one and degrading is the
documented trade (#1848, #1905). Reading the state the decision is about
avoids enumerating error codes per platform — and an enumeration is exactly
what let `ENOTEMPTY` through.

**Identity was captured after the acquire promise settled.** That span
includes the library's own `utimes`/`stat` probe, so a waiter replacing our
directory inside it would have us record *the winner's* identity as our own,
after which the removal guard would accept and delete their lock. It is now
recorded in the `mkdir` callback — the moment the directory becomes ours, and
synchronous with respect to this process, so there is no window.

**The expiry advice was still wrong for `EACCES`/`EPERM`/`EROFS`.** Since the
guard `stat`s before removing, everything reaching the release catch is an
`rmdir` that was refused — and stale takeover reclaims through that same
`rmdir`. The `ENOENT` arm was unreachable for the same reason, so the branch
was dead code: removed, and the advice is now unconditional.

**`proper-lockfile` was missing from `NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE`.** A
fourth externalization surface — the tsup lists configure the production
bundles, not `vite dev`, whose dep scanner would otherwise walk its
CJS/`graceful-fs`/`signal-exit` graph. Added, and `vite-base-config.test.ts`
now asserts it along with `chokidar` and `@napi-rs/keyring`, which were also
absent from that assertion.

**A test was named for something it did not exercise.** "does not treat an
unlistable directory as an empty one" was driving `ENOTDIR`, which is
deliberately treated *as* the fresh-install case, so the actual new behaviour
was untested. Split in two: one for `ENOTDIR`, and a real `EACCES` one using
a `--x` directory — `readdir` denied, a known name still reachable, which is
the asymmetry that makes "unlistable" different from "empty". Driven with a
real mode rather than a stub, since `vi.spyOn` cannot redefine an ESM
namespace export; skipped as root and on Windows, where the mode would not
bite and the test would assert nothing.

Both new tests verified by mutation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 11:31:37 -04:00

80 lines
2.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
existsSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
clearViteDepsCache,
getViteBaseConfig,
getViteDevOptimizeDeps,
} from "../../../../server/vite-base-config.js";
describe("getViteBaseConfig", () => {
it("excludes node-only deps from optimizeDeps so vite dev doesn't scan them", () => {
const config = getViteBaseConfig();
expect(config.optimizeDeps.exclude).toEqual(
expect.arrayContaining([
"@modelcontextprotocol/client/stdio",
"atomically",
"chokidar",
"cross-spawn",
"which",
"@napi-rs/keyring",
// #2082 — reached through `core/auth/node/file-lock.ts`. The tsup
// `external` lists cover the production bundles, not `vite dev`, so
// a node-only dependency has to be named in both places.
"proper-lockfile",
]),
);
});
it("returns a fresh object each call (callers can mutate safely)", () => {
const a = getViteBaseConfig();
const b = getViteBaseConfig();
expect(a).not.toBe(b);
expect(a.optimizeDeps).not.toBe(b.optimizeDeps);
});
});
describe("getViteDevOptimizeDeps", () => {
it("forces a full pre-bundle on each dev launch with no stale-request 504s", () => {
const config = getViteDevOptimizeDeps();
expect(config.force).toBe(true);
expect(config.ignoreOutdatedRequests).toBe(true);
expect(config.include).toEqual([
"ajv",
"@modelcontextprotocol/client/validators/ajv",
]);
expect(config.exclude).toEqual(getViteBaseConfig().optimizeDeps.exclude);
});
});
describe("clearViteDepsCache", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "vite-cache-clear-"));
mkdirSync(join(tempRoot, "node_modules", ".vite", "deps"), {
recursive: true,
});
writeFileSync(
join(tempRoot, "node_modules", ".vite", "deps", "metadata.json"),
"{}",
);
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it("removes node_modules/.vite before a dev server start", () => {
expect(existsSync(join(tempRoot, "node_modules", ".vite"))).toBe(true);
clearViteDepsCache(tempRoot);
expect(existsSync(join(tempRoot, "node_modules", ".vite"))).toBe(false);
});
});