Files
dingtalk-workspace-cli/test/scripts/install_js_smoke.mjs
T
玉澜andCursor 402429ac2a feat(skill): default installs and upgrades to multi-skill layout
Flip the agent-skill default from mono (single dws/ dir) to multi
(per-product dingtalk-* + dws-shared) across all distribution faces,
and fix the upgrade path so it no longer re-installs mono alongside
multi (mono+multi co-existence bug).

- upgrade: LocateSkillsRoot prefers the zip multi/ tree; multi refresh
  removes mono leftovers and stale skills, refreshes the multi cache
- install.sh/ps1/install-skills.sh/npm install.js: multi real-install
  (was print-only), default flipped, mono stays opt-in via DWS_SKILL_MODE
- skill setup: non-interactive default multi; full installs now clean
  stale dingtalk-*/dws-shared with confirm-preview disclosure, filtered
  (-s/-x) installs stay additive
- mutual exclusion is symmetric and includes dws-shared (previously
  leaked through the dingtalk- prefix) on all faces
- install.js: guard empty/corrupt multi trees (fall back to mono),
  validate SKILL.md on the mono branch, guard cache refreshes
- docs: roadmap (8/30 back-schedule), migration plan, distribution
  mechanism, rollout capability, capability completion, architecture
  optimization, wukong comparison (archived; line retired)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 14:13:45 +08:00

222 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
/**
* install_js_smoke.mjs — smoke test for build/npm/install.js (npm postinstall).
*
* Runs the REAL build/npm/install.js against a staged fake package:
*
* <tmp>/pkg/
* install.js (copied from build/npm/install.js)
* assets/
* dws-<os>-<arch>.tar.gz (dummy archive holding a fake `dws` binary)
* dws-skills.zip (tiny release-layout fixture built on the fly,
* NOT the real skills/ tree)
*
* Scenarios (each with an isolated fake HOME):
* 1. multi install — dingtalk-* and dws-shared land as sibling
* skills, mono leftover dws/ and stale
* dingtalk-* are removed, and the
* ~/.dws/skills/{multi,mono} caches fill.
* 2. empty multi/ tree — warns and falls back to mono instead of
* crashing postinstall; a previously good multi
* cache is NOT wiped.
* 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an
* "invalid DWS_SKILL_MODE" error.
* 4. multi-only zip, mono — mono install is skipped with a warning; the
* staging root is NOT copied into a dws/ dir.
*
* Requirements: unix host with tar/zip/unzip on PATH (the same tools
* install.js itself shells out to). Skips cleanly on win32.
*
* Usage (standalone; there is intentionally no Go test harness for the npm
* installer — test/scripts/install_script_test.go only execs POSIX sh):
*
* node test/scripts/install_js_smoke.mjs # self-contained, <10s
*/
import assert from "node:assert/strict";
import childProcess from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import url from "node:url";
const PLATFORM_MAP = {
"darwin-x64": "dws-darwin-amd64.tar.gz",
"darwin-arm64": "dws-darwin-arm64.tar.gz",
"linux-x64": "dws-linux-amd64.tar.gz",
"linux-arm64": "dws-linux-arm64.tar.gz",
};
const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..", "..");
const installJsSource = path.join(repoRoot, "build", "npm", "install.js");
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (process.platform === "win32" || !assetName) {
console.log(`SKIP: install.js smoke test needs a unix host with tar/zip/unzip (got ${process.platform}-${process.arch})`);
process.exit(0);
}
for (const tool of ["tar", "zip", "unzip"]) {
try {
childProcess.execFileSync("sh", ["-c", `command -v ${tool}`], { stdio: "ignore" });
} catch {
console.log(`SKIP: required tool not on PATH: ${tool}`);
process.exit(0);
}
}
function sh(command, args, options = {}) {
childProcess.execFileSync(command, args, { stdio: "ignore", ...options });
}
function writeFile(filePath, content, mode = 0o644) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, { mode });
}
// stagePkg builds a fake npm package whose assets/dws-skills.zip contains
// exactly the given zip entries ({ "relative/path": "content" }) plus any
// listed empty directories. Returns { tmp, pkg, home }.
function stagePkg(zipEntries, emptyDirs = []) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-smoke-"));
const pkg = path.join(tmp, "pkg");
const assets = path.join(pkg, "assets");
fs.mkdirSync(assets, { recursive: true });
fs.copyFileSync(installJsSource, path.join(pkg, "install.js"));
const binStage = path.join(tmp, "bin-stage");
writeFile(path.join(binStage, "dws"), "#!/bin/sh\necho fake-dws\n", 0o755);
sh("tar", ["-czf", path.join(assets, assetName), "-C", binStage, "."]);
const zipStage = path.join(tmp, "zip-stage");
for (const [rel, content] of Object.entries(zipEntries)) {
writeFile(path.join(zipStage, rel), content);
}
for (const dir of emptyDirs) {
fs.mkdirSync(path.join(zipStage, dir), { recursive: true });
}
sh("zip", ["-qr", path.join(assets, "dws-skills.zip"), "."], { cwd: zipStage });
return { tmp, pkg, home: path.join(tmp, "home") };
}
function runInstall(pkg, home, skillMode) {
const env = { ...process.env, HOME: home };
if (skillMode !== undefined) {
env.DWS_SKILL_MODE = skillMode;
} else {
delete env.DWS_SKILL_MODE;
}
return childProcess.spawnSync(process.execPath, [path.join(pkg, "install.js")], {
env,
encoding: "utf8",
});
}
const scenarios = [];
function scenario(name, fn) {
scenarios.push([name, fn]);
}
scenario("multi install lays out sibling skills and caches", () => {
const { tmp, pkg, home } = stagePkg({
"SKILL.md": "# mono root copy\n",
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
"multi/dingtalk-test/references/guide.md": "guide\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
// Pre-existing state the multi install must reconcile.
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
writeFile(path.join(home, ".agents", "skills", "dingtalk-stale", "SKILL.md"), "stale\n");
writeFile(path.join(home, ".agents", "skills", "other-skill", "SKILL.md"), "not dws\n");
const res = runInstall(pkg, home, undefined); // default mode = multi
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stdout, /Skill mode: multi/);
const base = path.join(home, ".agents", "skills");
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "SKILL.md")), "dingtalk-test installed");
assert.ok(fs.existsSync(path.join(base, "dingtalk-test", "references", "guide.md")), "references copied");
assert.ok(fs.existsSync(path.join(base, "dws-shared", "SKILL.md")), "dws-shared installed");
assert.ok(!fs.existsSync(path.join(base, "dws")), "mono leftover removed");
assert.ok(!fs.existsSync(path.join(base, "dingtalk-stale")), "stale skill removed");
assert.ok(fs.existsSync(path.join(base, "other-skill", "SKILL.md")), "non-DWS skill preserved");
assert.ok(fs.existsSync(path.join(home, ".dws", "skills", "multi", "dingtalk-test", "SKILL.md")), "multi cache filled");
assert.equal(fs.readFileSync(path.join(home, ".dws", "skills", "mono", "SKILL.md"), "utf8"), "# mono fixture\n", "mono cache from mono/ tree");
assert.ok(fs.existsSync(path.join(pkg, "vendor", "dws")), "binary installed into vendor/");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => {
const { tmp, pkg, home } = stagePkg({
"SKILL.md": "# mono root copy\n",
"mono/SKILL.md": "# mono fixture\n",
// Corrupt multi tree: a product subdir without SKILL.md.
"multi/dingtalk-broken/references/guide.md": "orphan\n",
});
try {
writeFile(path.join(home, ".agents", "skills", "dws", "SKILL.md"), "old mono\n");
// A previously good multi cache must survive an empty/corrupt bundle.
writeFile(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "good cache\n");
const res = runInstall(pkg, home, "multi");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stdout, /falling back to mono/);
const base = path.join(home, ".agents", "skills");
assert.equal(fs.readFileSync(path.join(base, "dws", "SKILL.md"), "utf8"), "# mono fixture\n", "mono installed from mono/ tree");
assert.ok(!fs.existsSync(path.join(base, "dingtalk-broken")), "broken multi skill not installed");
assert.equal(
fs.readFileSync(path.join(home, ".dws", "skills", "multi", "dingtalk-good", "SKILL.md"), "utf8"),
"good cache\n",
"previously good multi cache must not be wiped",
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("bogus DWS_SKILL_MODE fails fast with a clear error", () => {
const { tmp, pkg, home } = stagePkg({
"mono/SKILL.md": "# mono fixture\n",
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
});
try {
const res = runInstall(pkg, home, "bogus");
assert.notEqual(res.status, 0, "bogus mode must exit non-zero");
assert.match(res.stderr, /invalid DWS_SKILL_MODE/);
assert.ok(!fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-test")), "nothing installed on mode error");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
scenario("multi-only zip in mono mode skips skill install instead of copying staging root", () => {
const { tmp, pkg, home } = stagePkg({
// No root SKILL.md and no mono/ tree — a multi-only release layout.
"multi/dingtalk-test/SKILL.md": "# dingtalk-test\n",
"multi/dws-shared/SKILL.md": "# dws-shared\n",
});
try {
const res = runInstall(pkg, home, "mono");
assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`);
assert.match(res.stdout, /mono skill tree not found.*skipping skill install/s);
const base = path.join(home, ".agents", "skills");
assert.ok(!fs.existsSync(path.join(base, "dws")), "staging root must not be copied into dws/");
assert.ok(!fs.existsSync(path.join(home, ".dws", "skills", "mono")), "mono cache not refreshed without a mono tree");
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
for (const [name, fn] of scenarios) {
process.stdout.write(`• ${name} ... `);
fn();
process.stdout.write("ok\n");
}
console.log(`OK — ${scenarios.length} install.js smoke scenarios passed`);