Skip to content

Commit 1262dd2

Browse files
committed
chore(cli): add unit tests
1 parent ff84a84 commit 1262dd2

File tree

7 files changed

+307
-14
lines changed

7 files changed

+307
-14
lines changed

dist/cli.js

Lines changed: 74 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/cli.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,13 @@
1111
"README.md",
1212
"LICENSE"
1313
],
14-
1514
"publishConfig": {
1615
"access": "public"
1716
},
1817
"type": "module",
19-
2018
"engines": {
2119
"node": ">=20"
2220
},
23-
2421
"scripts": {
2522
"build": "tsup src/cli.ts --format esm --target node18 --sourcemap --clean",
2623
"test": "npm run build && NODE_ENV=test node --test",
@@ -46,15 +43,16 @@
4643
"progress": "^2.0.3"
4744
},
4845
"devDependencies": {
49-
"@types/node": "^25.0.3",
50-
"tsup": "^8.5.1",
51-
"typescript": "^5.9.3",
52-
"semantic-release": "^24.0.0",
53-
"@semantic-release/commit-analyzer": "^13.0.0",
54-
"@semantic-release/release-notes-generator": "^14.0.0",
5546
"@semantic-release/changelog": "^6.0.0",
56-
"@semantic-release/npm": "^12.0.0",
47+
"@semantic-release/commit-analyzer": "^13.0.0",
48+
"@semantic-release/git": "^10.0.0",
5749
"@semantic-release/github": "^11.0.0",
58-
"@semantic-release/git": "^10.0.0"
50+
"@semantic-release/npm": "^12.0.0",
51+
"@semantic-release/release-notes-generator": "^14.0.0",
52+
"@types/node": "^25.0.3",
53+
"@types/progress": "^2.0.7",
54+
"semantic-release": "^24.0.0",
55+
"tsup": "^8.5.1",
56+
"typescript": "^5.9.3"
5957
}
60-
}
58+
}

src/cli.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ function normalizeSampleArg(sample: string): string {
3939
if (s.startsWith("samples/")) return s.slice("samples/".length);
4040
return s;
4141
}
42+
export { normalizeSampleArg };
4243

4344
async function pathExists(p: string): Promise<boolean> {
4445
try {
@@ -115,6 +116,7 @@ function parseGitVersion(output: string): { major: number; minor: number; patch:
115116
if (!m) return null;
116117
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
117118
}
119+
export { parseGitVersion };
118120

119121
function versionGte(
120122
v: { major: number; minor: number; patch: number },
@@ -124,6 +126,7 @@ function versionGte(
124126
if (v.minor !== min.minor) return v.minor > min.minor;
125127
return v.patch >= min.patch;
126128
}
129+
export { versionGte };
127130

128131
async function ensureGit(verbose?: boolean): Promise<void> {
129132
let res: RunResult;
@@ -161,6 +164,7 @@ function assertMethod(m: string | undefined): Method {
161164
if (m === "auto" || m === "git" || m === "api") return m;
162165
throw new Error(`Invalid --method "${m}". Use "auto", "git", or "api".`);
163166
}
167+
export { assertMethod };
164168

165169
async function copyDir(src: string, dest: string): Promise<void> {
166170
await fs.mkdir(dest, { recursive: true });
@@ -384,11 +388,13 @@ function assertMode(m: string | undefined): Mode {
384388
if (m === "extract" || m === "repo") return m;
385389
throw new Error(`Invalid --mode "${m}". Use "extract" or "repo".`);
386390
}
391+
export { assertMode };
387392

388393
function isGuid(v: string): boolean {
389394
// Accepts RFC4122-ish GUIDs (case-insensitive)
390395
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v);
391396
}
397+
export { isGuid };
392398

393399
async function readJsonIfExists<T>(filePath: string): Promise<T | null> {
394400
try {
@@ -732,6 +738,94 @@ program
732738
}
733739
});
734740

741+
/**
742+
* Testable handler for the `get` command. Allows injecting dependencies for unit testing.
743+
*/
744+
export async function getCommandHandler(sample: string, options: CliOptions, deps?: {
745+
download?: typeof downloadSampleViaGitHubSubtree;
746+
fetchSparse?: typeof fetchSampleViaSparseGitExtract;
747+
sparseClone?: typeof sparseCloneInto;
748+
postProcess?: typeof postProcessProject;
749+
finalize?: typeof finalizeExtraction;
750+
isGitAvailable?: typeof isGitAvailable;
751+
ensureGit?: typeof ensureGit;
752+
}) {
753+
const sampleFolder = normalizeSampleArg(sample);
754+
const ref = options.ref || DEFAULT_REF;
755+
const repo = options.repo || DEFAULT_REPO;
756+
const owner = options.owner || DEFAULT_OWNER;
757+
const verbose = !!options.verbose;
758+
759+
const download = deps?.download ?? downloadSampleViaGitHubSubtree;
760+
const fetchSparse = deps?.fetchSparse ?? fetchSampleViaSparseGitExtract;
761+
const sparseClone = deps?.sparseClone ?? sparseCloneInto;
762+
const postProcess = deps?.postProcess ?? postProcessProject;
763+
const finalize = deps?.finalize ?? finalizeExtraction;
764+
const gitAvailableFn = deps?.isGitAvailable ?? isGitAvailable;
765+
const ensureGitFn = deps?.ensureGit ?? ensureGit;
766+
767+
let mode: Mode;
768+
try {
769+
mode = assertMode(options.mode);
770+
} catch (e) {
771+
throw e;
772+
}
773+
774+
let method: Method;
775+
try {
776+
method = assertMethod(options.method);
777+
} catch (e) {
778+
throw e;
779+
}
780+
781+
const defaultDest = mode === "extract" ? `./${sampleFolder}` : `./${repo}-${sampleFolder}`.replaceAll("/", "-");
782+
const destDir = path.resolve(options.dest ?? defaultDest);
783+
784+
const gitAvailable = await gitAvailableFn(verbose);
785+
const chosen: Method = method === "auto" ? (gitAvailable ? "git" : "api") : method;
786+
787+
if (chosen === "git") {
788+
await ensureGitFn(verbose);
789+
}
790+
791+
if (chosen === "api" && mode === "repo") {
792+
throw new Error(`--mode repo requires --method git (API method cannot create a git working repo).`);
793+
}
794+
795+
if (await pathExists(destDir)) {
796+
if (!options.force) {
797+
const nonEmpty = await isDirNonEmpty(destDir);
798+
if (nonEmpty) throw new Error(`Destination exists and is not empty: ${destDir}`);
799+
} else {
800+
await fs.rm(destDir, { recursive: true, force: true });
801+
}
802+
}
803+
804+
if (chosen === "api") {
805+
await fs.mkdir(destDir, { recursive: true });
806+
await download({ owner, repo, ref, sampleFolder, destDir, concurrency: 8, verbose, signal: undefined, onProgress: undefined });
807+
await postProcess(destDir, options, undefined);
808+
await finalize({ spinner: undefined, successMessage: `Done`, projectPath: destDir });
809+
return;
810+
}
811+
812+
if (chosen === "git") {
813+
if (mode === "extract") {
814+
await fetchSparse({ owner, repo, ref, sampleFolder, destDir, verbose, spinner: undefined, signal: undefined });
815+
await postProcess(destDir, options, undefined);
816+
await finalize({ spinner: undefined, successMessage: `Done`, projectPath: destDir });
817+
return;
818+
} else {
819+
await fs.mkdir(destDir, { recursive: true });
820+
await sparseClone({ owner, repo, ref, sampleFolder, repoDir: destDir, verbose, spinner: undefined, signal: undefined });
821+
const samplePath = path.join(destDir, "samples", sampleFolder);
822+
await postProcess(samplePath, options, undefined);
823+
await finalize({ spinner: undefined, successMessage: `Done`, projectPath: samplePath, repoRoot: destDir });
824+
return;
825+
}
826+
}
827+
}
828+
735829
program
736830
.command("rename")
737831
.argument("<path>", "Path to previously downloaded sample folder (project root)")

test/get.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import test from 'node:test';
2+
import { strict as assert } from 'node:assert';
3+
import fs from 'node:fs/promises';
4+
import path from 'node:path';
5+
import os from 'node:os';
6+
import { getCommandHandler } from '../dist/cli.js';
7+
8+
async function mkTmpDir() {
9+
return await fs.mkdtemp(path.join(os.tmpdir(), 'spfx-get-'));
10+
}
11+
12+
test('getCommandHandler uses API download when git unavailable', async () => {
13+
const tmp = await mkTmpDir();
14+
let downloaded = false;
15+
const fakeDownload = async (opts) => {
16+
downloaded = true;
17+
// create a package.json to simulate download
18+
await fs.writeFile(path.join(opts.destDir, 'package.json'), JSON.stringify({ name: 'downloaded' }, null, 2));
19+
};
20+
await getCommandHandler('react-hello-world', { method: 'auto', mode: 'extract', dest: tmp }, {
21+
download: fakeDownload,
22+
isGitAvailable: async () => false,
23+
ensureGit: async () => {},
24+
postProcess: async () => {},
25+
finalize: async () => {}
26+
});
27+
28+
assert.ok(downloaded);
29+
const pkg = JSON.parse(await fs.readFile(path.join(tmp, 'package.json'), 'utf8'));
30+
assert.equal(pkg.name, 'downloaded');
31+
});
32+
33+
test('getCommandHandler throws when dest exists and not force', async () => {
34+
const tmp = await mkTmpDir();
35+
await fs.writeFile(path.join(tmp, 'existing.txt'), 'x');
36+
let threw = false;
37+
try {
38+
await getCommandHandler('react-hello-world', { method: 'api', mode: 'extract', dest: tmp }, {
39+
download: async () => {},
40+
isGitAvailable: async () => false,
41+
ensureGit: async () => {},
42+
postProcess: async () => {},
43+
finalize: async () => {}
44+
});
45+
} catch (e) {
46+
threw = true;
47+
}
48+
assert.ok(threw);
49+
});
50+
51+
test('getCommandHandler accepts --force and overwrites', async () => {
52+
const tmp = await mkTmpDir();
53+
await fs.writeFile(path.join(tmp, 'existing.txt'), 'x');
54+
let downloaded = false;
55+
const fakeDownload = async (opts) => {
56+
downloaded = true;
57+
await fs.writeFile(path.join(opts.destDir, 'package.json'), JSON.stringify({ name: 'forced' }, null, 2));
58+
};
59+
await getCommandHandler('react-hello-world', { method: 'api', mode: 'extract', dest: tmp, force: true }, {
60+
download: fakeDownload,
61+
isGitAvailable: async () => false,
62+
ensureGit: async () => {},
63+
postProcess: async () => {},
64+
finalize: async () => {}
65+
});
66+
assert.ok(downloaded);
67+
const pkg = JSON.parse(await fs.readFile(path.join(tmp, 'package.json'), 'utf8'));
68+
assert.equal(pkg.name, 'forced');
69+
});

0 commit comments

Comments
 (0)