196 lines
7.2 KiB
JavaScript
196 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// build-jetbrains-fixtures.mjs — Deterministic fixture-jar builder.
|
|
//
|
|
// Walks `tests/fixtures/ide-extensions/root-jetbrains/` and for every plugin
|
|
// directory containing a `source/` subtree, produces `lib/<pluginId>.jar` by
|
|
// packing the source files into a real ZIP with JetBrains-plugin layout:
|
|
//
|
|
// source/plugin.xml → META-INF/plugin.xml (inside jar)
|
|
// source/MANIFEST.MF → META-INF/MANIFEST.MF (inside jar)
|
|
// source/native/foo.so → native/foo.so (inside jar)
|
|
// source/<other path> → <other path> (inside jar)
|
|
//
|
|
// Invariants:
|
|
// - Deterministic: stored-method, fixed DOS timestamp, sorted entries.
|
|
// Same input → byte-identical output across Node versions / platforms.
|
|
// - Idempotent + race-safe: each jar write is atomic (temp-then-rename).
|
|
// If the target file already exists with the expected SHA-256 (computed
|
|
// from the intended bytes), the write is skipped. Concurrent callers are
|
|
// safe because rename is atomic on the same filesystem and every writer
|
|
// produces identical bytes.
|
|
// - Zero dependencies. Uses `createZip` from `../helpers/zip-writer.mjs`.
|
|
//
|
|
// Public API:
|
|
// export function buildJetBrainsFixtures({ fixtureRoot? }) → { fixtureRoot, builtJars }
|
|
// export function writeJar(finalPath, entries) → { path, sha256, skipped }
|
|
//
|
|
// CLI: `node tests/helpers/build-jetbrains-fixtures.mjs` — walks the default
|
|
// fixture root, builds all jars, prints a one-line summary per jar.
|
|
//
|
|
// Called by:
|
|
// - tests/scanners/ide-extension-scanner.test.mjs (Step 14 integration)
|
|
// - any future test that needs the synthetic JetBrains plugin tree
|
|
//
|
|
// See: plan step 13 (`ultraplan-2026-04-17-jetbrains-ide-scan.md`).
|
|
|
|
import { readdir, mkdir, rename, writeFile, readFile, stat } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { dirname, join, relative, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createHash } from 'node:crypto';
|
|
import { createZip } from './zip-writer.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const DEFAULT_FIXTURE_ROOT = join(
|
|
__dirname,
|
|
'..',
|
|
'fixtures',
|
|
'ide-extensions',
|
|
'root-jetbrains',
|
|
);
|
|
|
|
function sha256(buf) {
|
|
return createHash('sha256').update(buf).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Recursively walk a directory, returning relative paths of regular files.
|
|
* Relative paths use forward slashes (ZIP spec).
|
|
*/
|
|
async function walkFiles(root, rel = '') {
|
|
const out = [];
|
|
const full = rel ? join(root, rel) : root;
|
|
const entries = await readdir(full, { withFileTypes: true });
|
|
for (const ent of entries) {
|
|
const sub = rel ? `${rel}/${ent.name}` : ent.name;
|
|
if (ent.isDirectory()) {
|
|
out.push(...(await walkFiles(root, sub)));
|
|
} else if (ent.isFile()) {
|
|
out.push(sub);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Map a `source/`-relative path to the in-jar path.
|
|
*
|
|
* source/plugin.xml → META-INF/plugin.xml
|
|
* source/MANIFEST.MF → META-INF/MANIFEST.MF
|
|
* source/native/foo.so → native/foo.so
|
|
* source/<other> → <other>
|
|
*/
|
|
function sourceToJarPath(srcRelPath) {
|
|
const p = srcRelPath.replace(/\\/g, '/');
|
|
if (p === 'plugin.xml') return 'META-INF/plugin.xml';
|
|
if (p === 'MANIFEST.MF') return 'META-INF/MANIFEST.MF';
|
|
return p;
|
|
}
|
|
|
|
/**
|
|
* Atomically write a file by staging into `<path>.tmp.<pid>` and renaming.
|
|
* Rename is atomic on the same filesystem; if two writers race, the last
|
|
* rename wins — and since callers here pass identical bytes for a given
|
|
* fixture input, the final file is byte-identical regardless of winner.
|
|
*/
|
|
async function atomicWrite(finalPath, bytes) {
|
|
await mkdir(dirname(finalPath), { recursive: true });
|
|
const tmp = `${finalPath}.tmp.${process.pid}`;
|
|
await writeFile(tmp, bytes);
|
|
await rename(tmp, finalPath);
|
|
}
|
|
|
|
/**
|
|
* Build a single jar from a list of `{ name, data }` entries and write it to
|
|
* `finalPath`. Skips the write if `finalPath` already exists with the
|
|
* expected SHA-256 — makes the function idempotent and safe to call
|
|
* repeatedly (including concurrently from multiple test.before hooks).
|
|
*
|
|
* @returns {{ path: string, sha256: string, skipped: boolean }}
|
|
*/
|
|
export async function writeJar(finalPath, entries) {
|
|
const bytes = createZip(entries);
|
|
const expected = sha256(bytes);
|
|
|
|
if (existsSync(finalPath)) {
|
|
try {
|
|
const existing = await readFile(finalPath);
|
|
if (sha256(existing) === expected) {
|
|
return { path: finalPath, sha256: expected, skipped: true };
|
|
}
|
|
} catch {
|
|
// Fall through and rewrite.
|
|
}
|
|
}
|
|
|
|
await atomicWrite(finalPath, bytes);
|
|
return { path: finalPath, sha256: expected, skipped: false };
|
|
}
|
|
|
|
/**
|
|
* Build fixture jars for every plugin directory under the fixture root.
|
|
* A plugin directory is detected by the presence of a `source/plugin.xml`.
|
|
* The produced jar lands at `<pluginDir>/lib/<pluginDirName>.jar`.
|
|
*
|
|
* @param {{ fixtureRoot?: string }} [opts]
|
|
* @returns {Promise<{ fixtureRoot: string, builtJars: Array<{ path: string, sha256: string, skipped: boolean, pluginId: string }> }>}
|
|
*/
|
|
export async function buildJetBrainsFixtures(opts = {}) {
|
|
const fixtureRoot = opts.fixtureRoot || DEFAULT_FIXTURE_ROOT;
|
|
if (!existsSync(fixtureRoot)) {
|
|
throw new Error(`fixture root does not exist: ${fixtureRoot}`);
|
|
}
|
|
|
|
const builtJars = [];
|
|
|
|
// Walk two levels deep: <fixtureRoot>/<IDE>/plugins/<pluginId>/source/plugin.xml
|
|
async function walkIDEs() {
|
|
const ideDirs = await readdir(fixtureRoot, { withFileTypes: true });
|
|
for (const ide of ideDirs) {
|
|
if (!ide.isDirectory()) continue;
|
|
const pluginsDir = join(fixtureRoot, ide.name, 'plugins');
|
|
if (!existsSync(pluginsDir)) continue;
|
|
const pluginEntries = await readdir(pluginsDir, { withFileTypes: true });
|
|
for (const p of pluginEntries) {
|
|
if (!p.isDirectory()) continue;
|
|
const pluginDir = join(pluginsDir, p.name);
|
|
const sourceDir = join(pluginDir, 'source');
|
|
if (!existsSync(join(sourceDir, 'plugin.xml'))) continue;
|
|
|
|
// Build jar entries from source tree.
|
|
const files = await walkFiles(sourceDir);
|
|
const entries = [];
|
|
for (const rel of files) {
|
|
const abs = join(sourceDir, rel);
|
|
const data = await readFile(abs);
|
|
entries.push({ name: sourceToJarPath(rel), data });
|
|
}
|
|
|
|
const jarPath = join(pluginDir, 'lib', `${p.name}.jar`);
|
|
const result = await writeJar(jarPath, entries);
|
|
builtJars.push({ ...result, pluginId: p.name });
|
|
}
|
|
}
|
|
}
|
|
|
|
await walkIDEs();
|
|
return { fixtureRoot, builtJars };
|
|
}
|
|
|
|
// CLI mode — invoked via `node tests/helpers/build-jetbrains-fixtures.mjs`.
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
buildJetBrainsFixtures()
|
|
.then(({ fixtureRoot, builtJars }) => {
|
|
const root = relative(process.cwd(), fixtureRoot);
|
|
for (const j of builtJars) {
|
|
const rel = relative(process.cwd(), j.path);
|
|
const tag = j.skipped ? 'skip' : ' new';
|
|
process.stdout.write(`[${tag}] ${rel}\n`);
|
|
}
|
|
process.stdout.write(`built ${builtJars.length} jar(s) under ${root}\n`);
|
|
})
|
|
.catch((err) => {
|
|
process.stderr.write(`build-jetbrains-fixtures: ${err.stack || err.message}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|