git0 0.3.35 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +40 -34
- package/dist/cli.js.map +7 -7
- package/package.json +1 -1
- package/src/fm.js +22 -6
- package/src/github-api.ts +18 -4
- package/src/ide.ts +3 -3
- package/src/install.ts +2 -3
- package/src/utils.ts +12 -2
- package/test/cli.test.ts +51 -0
- package/test/download-package.test.ts +153 -0
- package/test/exec.test.ts +38 -0
- package/test/fm.test.ts +990 -0
- package/test/github-api-http.test.ts +159 -0
- package/test/helpers/fake-shell.ts +47 -0
- package/test/ide.test.ts +114 -0
- package/test/install.test.ts +132 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git0",
|
|
3
3
|
"description": "CLI tool to search GitHub repositories, download source & releases for your system, and instantly set up, then install dependencies and open code editor.",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.36",
|
|
5
5
|
"author": "vtempest",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "bun build src/cli.ts --outdir dist --target node --format esm --sourcemap",
|
package/src/fm.js
CHANGED
|
@@ -4,9 +4,10 @@ import path from 'path';
|
|
|
4
4
|
import readline from 'readline';
|
|
5
5
|
import { execSync } from 'child_process';
|
|
6
6
|
import os from 'os';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
7
8
|
|
|
8
9
|
|
|
9
|
-
class FileManager {
|
|
10
|
+
export class FileManager {
|
|
10
11
|
constructor() {
|
|
11
12
|
this.currentPath = process.cwd();
|
|
12
13
|
this.selectedIndex = 0;
|
|
@@ -765,8 +766,8 @@ class FileManager {
|
|
|
765
766
|
const selected = this.items[this.selectedIndex];
|
|
766
767
|
|
|
767
768
|
if (selected.isDirectory) {
|
|
768
|
-
this.addToHistory(this.currentPath);
|
|
769
769
|
this.currentPath = selected.fullPath;
|
|
770
|
+
this.addToHistory(this.currentPath);
|
|
770
771
|
this.selectedIndex = 0;
|
|
771
772
|
this.selectedItems.clear();
|
|
772
773
|
await this.loadDirectory();
|
|
@@ -786,14 +787,15 @@ class FileManager {
|
|
|
786
787
|
async goUp() {
|
|
787
788
|
const parentPath = path.dirname(this.currentPath);
|
|
788
789
|
if (parentPath !== this.currentPath) {
|
|
789
|
-
this.addToHistory(this.currentPath);
|
|
790
790
|
this.currentPath = parentPath;
|
|
791
|
+
this.addToHistory(this.currentPath);
|
|
791
792
|
this.selectedIndex = 0;
|
|
792
793
|
this.selectedItems.clear();
|
|
793
794
|
await this.loadDirectory();
|
|
794
795
|
}
|
|
795
796
|
}
|
|
796
797
|
|
|
798
|
+
// Records the directory just navigated *to*, so back/forward can return to it.
|
|
797
799
|
addToHistory(path) {
|
|
798
800
|
// Remove forward history when adding new path
|
|
799
801
|
this.history = this.history.slice(0, this.historyIndex + 1);
|
|
@@ -990,8 +992,8 @@ class FileManager {
|
|
|
990
992
|
const bookmarkPath = this.bookmarks[bookmarkName];
|
|
991
993
|
|
|
992
994
|
if (fs.existsSync(bookmarkPath)) {
|
|
993
|
-
this.addToHistory(this.currentPath);
|
|
994
995
|
this.currentPath = bookmarkPath;
|
|
996
|
+
this.addToHistory(this.currentPath);
|
|
995
997
|
this.selectedIndex = 0;
|
|
996
998
|
this.selectedItems.clear();
|
|
997
999
|
await this.loadDirectory();
|
|
@@ -1126,5 +1128,19 @@ class FileManager {
|
|
|
1126
1128
|
}
|
|
1127
1129
|
}
|
|
1128
1130
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
+
/**
|
|
1132
|
+
* True when this file is the program being run (the `fm` bin), as opposed to
|
|
1133
|
+
* being imported — so importing the class for tests doesn't take over the
|
|
1134
|
+
* terminal. `realpath` on both sides because npm links bins through symlinks.
|
|
1135
|
+
*/
|
|
1136
|
+
function isMainModule() {
|
|
1137
|
+
try {
|
|
1138
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1139
|
+
} catch {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (isMainModule()) {
|
|
1145
|
+
new FileManager().run();
|
|
1146
|
+
}
|
package/src/github-api.ts
CHANGED
|
@@ -104,6 +104,20 @@ export function parseTarget(query: string): RepoTarget | false {
|
|
|
104
104
|
* const repos = await github.searchRepositories('nodejs template');
|
|
105
105
|
* const dir = await github.downloadRepo('facebook/react');
|
|
106
106
|
*/
|
|
107
|
+
/**
|
|
108
|
+
* Coerces a `/releases` response to a plain array.
|
|
109
|
+
*
|
|
110
|
+
* `grab-api.js` hands a JSON array back as an array-like object
|
|
111
|
+
* (`{ 0: …, 1: …, data: […] }`), which has no `length`, so every
|
|
112
|
+
* `releases.length` check downstream would read as "no releases". Older
|
|
113
|
+
* versions return the bare array; accept both.
|
|
114
|
+
*/
|
|
115
|
+
function toReleaseList(response: any): any[] {
|
|
116
|
+
if (Array.isArray(response)) return response;
|
|
117
|
+
if (Array.isArray(response?.data)) return response.data;
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
class GithubAPI {
|
|
108
122
|
/** Default number of repository search results returned per query. */
|
|
109
123
|
static DEFAULT_RESULTS_PER_PAGE = 10;
|
|
@@ -216,8 +230,8 @@ class GithubAPI {
|
|
|
216
230
|
|
|
217
231
|
return Promise.all(
|
|
218
232
|
response.items.map(async (repo: SearchResult) => {
|
|
219
|
-
const releases =
|
|
220
|
-
`/repos/${repo.owner.login}/${repo.name}/releases`
|
|
233
|
+
const releases = toReleaseList(
|
|
234
|
+
await this.callGithub(`/repos/${repo.owner.login}/${repo.name}/releases`)
|
|
221
235
|
);
|
|
222
236
|
const platform = getCurrentPlatform();
|
|
223
237
|
|
|
@@ -321,7 +335,7 @@ class GithubAPI {
|
|
|
321
335
|
* const releases = await github.getReleases('microsoft', 'vscode');
|
|
322
336
|
*/
|
|
323
337
|
async getReleases(owner: string, repo: string): Promise<CategorizedRelease[]> {
|
|
324
|
-
const releases = await this.callGithub(`/repos/${owner}/${repo}/releases`);
|
|
338
|
+
const releases = toReleaseList(await this.callGithub(`/repos/${owner}/${repo}/releases`));
|
|
325
339
|
return categorizeReleasesByPlatform(releases);
|
|
326
340
|
}
|
|
327
341
|
|
|
@@ -335,7 +349,7 @@ class GithubAPI {
|
|
|
335
349
|
* current platform (including `universal` assets).
|
|
336
350
|
*/
|
|
337
351
|
async getCompatibleReleases(owner: string, repo: string): Promise<CategorizedRelease[]> {
|
|
338
|
-
const releases = await this.callGithub(`/repos/${owner}/${repo}/releases`);
|
|
352
|
+
const releases = toReleaseList(await this.callGithub(`/repos/${owner}/${repo}/releases`));
|
|
339
353
|
return filterReleasesByPlatform(releases, getCurrentPlatform());
|
|
340
354
|
}
|
|
341
355
|
}
|
package/src/ide.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { execSync, spawn } from 'child_process';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import path from 'path';
|
|
4
|
+
import { shell } from './utils.js';
|
|
5
5
|
import type { IdeInfo } from './types.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -39,7 +39,7 @@ export function getInstalledIde(): IdeInfo | null {
|
|
|
39
39
|
|
|
40
40
|
for (const ide of SUPPORTED_IDES) {
|
|
41
41
|
try {
|
|
42
|
-
execSync(probe(ide.cmd), { stdio: 'ignore' });
|
|
42
|
+
shell.execSync(probe(ide.cmd), { stdio: 'ignore' });
|
|
43
43
|
return ide;
|
|
44
44
|
} catch {
|
|
45
45
|
continue;
|
|
@@ -96,7 +96,7 @@ function findEntryDocument(dir = '.'): string | null {
|
|
|
96
96
|
* @internal
|
|
97
97
|
*/
|
|
98
98
|
function spawnDetached(ide: IdeInfo, args: string[]): void {
|
|
99
|
-
spawn(ide.cmd, args, {
|
|
99
|
+
shell.spawn(ide.cmd, args, {
|
|
100
100
|
detached: true,
|
|
101
101
|
stdio: 'ignore',
|
|
102
102
|
shell: process.platform === 'win32',
|
package/src/install.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { execSync } from 'child_process';
|
|
2
1
|
import fs from 'fs';
|
|
3
|
-
import { exec } from './utils.js';
|
|
2
|
+
import { exec, shell } from './utils.js';
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* A detector checks whether a specific project type is present in the current
|
|
@@ -28,7 +27,7 @@ type Installer = () => void;
|
|
|
28
27
|
*/
|
|
29
28
|
function bunAvailable(): boolean {
|
|
30
29
|
try {
|
|
31
|
-
execSync(
|
|
30
|
+
shell.execSync(
|
|
32
31
|
process.platform === 'win32' ? 'where bun' : 'command -v bun',
|
|
33
32
|
{ stdio: 'ignore' }
|
|
34
33
|
);
|
package/src/utils.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { execSync } from 'child_process';
|
|
2
|
+
import { execSync, spawn } from 'child_process';
|
|
3
3
|
|
|
4
4
|
/** Set once the logo has been printed, so repeat calls are no-ops. */
|
|
5
|
+
/**
|
|
6
|
+
* The process-spawning primitives the rest of the CLI shells out through.
|
|
7
|
+
*
|
|
8
|
+
* Routed through one object so tests can replace them: `bun test` (what CI runs
|
|
9
|
+
* for this package) supports neither `vi.mock` nor a runtime `PATH` change for
|
|
10
|
+
* `child_process`, so a seam like this is the only way to observe — rather than
|
|
11
|
+
* perform — an install or an IDE launch.
|
|
12
|
+
*/
|
|
13
|
+
export const shell = { execSync, spawn };
|
|
14
|
+
|
|
5
15
|
let logoPrinted = false;
|
|
6
16
|
|
|
7
17
|
/**
|
|
@@ -62,7 +72,7 @@ export function resetLogo(): void {
|
|
|
62
72
|
*/
|
|
63
73
|
export function exec(cmd: string, showError = false): void {
|
|
64
74
|
try {
|
|
65
|
-
execSync(cmd, { stdio: 'inherit' });
|
|
75
|
+
shell.execSync(cmd, { stdio: 'inherit' });
|
|
66
76
|
} catch {
|
|
67
77
|
if (showError) console.error(chalk.red(`❌ Failed: ${cmd}`));
|
|
68
78
|
}
|
package/test/cli.test.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Only the offline branch of the CLI entry is exercised here: with no
|
|
5
|
+
* arguments it prints usage and exits 1 before touching the network. The
|
|
6
|
+
* download and search branches talk to GitHub and are covered through
|
|
7
|
+
* downloadRepo / GithubAPI instead.
|
|
8
|
+
*/
|
|
9
|
+
describe('cli entry', () => {
|
|
10
|
+
let lines: string[];
|
|
11
|
+
let exits: Array<number | undefined>;
|
|
12
|
+
let origLog: typeof console.log;
|
|
13
|
+
let origErr: typeof console.error;
|
|
14
|
+
let origExit: typeof process.exit;
|
|
15
|
+
let origArgv: string[];
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
lines = [];
|
|
19
|
+
exits = [];
|
|
20
|
+
origLog = console.log;
|
|
21
|
+
origErr = console.error;
|
|
22
|
+
origExit = process.exit;
|
|
23
|
+
origArgv = process.argv;
|
|
24
|
+
console.log = (...a: unknown[]) => void lines.push(a.join(' '));
|
|
25
|
+
console.error = (...a: unknown[]) => void lines.push(a.join(' '));
|
|
26
|
+
(process as any).exit = (code?: number) => {
|
|
27
|
+
exits.push(code);
|
|
28
|
+
// First exit halts main(); the .catch handler's own exit must not throw.
|
|
29
|
+
if (exits.length === 1) throw new Error('process.exit');
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
console.log = origLog;
|
|
35
|
+
console.error = origErr;
|
|
36
|
+
process.exit = origExit;
|
|
37
|
+
process.argv = origArgv;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('prints usage and exits 1 when given no arguments', async () => {
|
|
41
|
+
process.argv = ['bun', 'cli.ts'];
|
|
42
|
+
await import('../src/cli.ts?no-args');
|
|
43
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
44
|
+
|
|
45
|
+
const text = lines.join('\n');
|
|
46
|
+
expect(text).toContain('Usage: git0 <github-url | owner/repo[/path] | search-query>');
|
|
47
|
+
expect(text).toContain('--history-only');
|
|
48
|
+
expect(text).toContain('--mirror');
|
|
49
|
+
expect(exits[0]).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { Readable } from 'stream';
|
|
6
|
+
import { downloadPackage, printInstallInstructions } from '../src/download.ts';
|
|
7
|
+
import { showPackageMenu, buildReleaseChoices } from '../src/package-menu.ts';
|
|
8
|
+
|
|
9
|
+
/** Captures console.log / console.error output for the duration of a test. */
|
|
10
|
+
function captureConsole() {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
const orig = { log: console.log, error: console.error };
|
|
13
|
+
console.log = (...a: unknown[]) => void lines.push(a.join(' '));
|
|
14
|
+
console.error = (...a: unknown[]) => void lines.push(a.join(' '));
|
|
15
|
+
return {
|
|
16
|
+
text: () => lines.join('\n'),
|
|
17
|
+
restore() {
|
|
18
|
+
console.log = orig.log;
|
|
19
|
+
console.error = orig.error;
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pretends to run on `platform` for both `process.platform` and `os.platform()`. */
|
|
25
|
+
async function withPlatform<T>(platform: string, fn: () => T | Promise<T>): Promise<T> {
|
|
26
|
+
const original = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
|
27
|
+
const originalOs = os.platform;
|
|
28
|
+
Object.defineProperty(process, 'platform', { value: platform });
|
|
29
|
+
(os as any).platform = () => platform;
|
|
30
|
+
try {
|
|
31
|
+
return await fn();
|
|
32
|
+
} finally {
|
|
33
|
+
Object.defineProperty(process, 'platform', original);
|
|
34
|
+
(os as any).platform = originalOs;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A `callGithub` stand-in that streams `body` to `onStream`, like `grab` does. */
|
|
39
|
+
const fakeGithub =
|
|
40
|
+
(body: string | Error) =>
|
|
41
|
+
async (_url: string, params: { onStream: (r: ReadableStream) => Promise<void> }) => {
|
|
42
|
+
if (body instanceof Error) throw body;
|
|
43
|
+
await params.onStream(Readable.toWeb(Readable.from([Buffer.from(body)])) as ReadableStream);
|
|
44
|
+
return {};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('downloadPackage', () => {
|
|
48
|
+
let tmp: string;
|
|
49
|
+
let con: ReturnType<typeof captureConsole>;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-pkg-'));
|
|
53
|
+
con = captureConsole();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
con.restore();
|
|
58
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('streams the asset to disk and returns its path', async () => {
|
|
62
|
+
const dest = path.join(tmp, 'app.deb');
|
|
63
|
+
const result = await downloadPackage(fakeGithub('payload'), 'https://x/app.deb', dest);
|
|
64
|
+
expect(result).toBe(dest);
|
|
65
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe('payload');
|
|
66
|
+
expect(con.text()).toContain('Downloaded app.deb');
|
|
67
|
+
expect(con.text()).toContain('sudo dpkg -i');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('marks extension-less binaries executable (non-Windows)', async () => {
|
|
71
|
+
const dest = path.join(tmp, 'tool');
|
|
72
|
+
await downloadPackage(fakeGithub('#!/bin/sh\n'), 'https://x/tool', dest);
|
|
73
|
+
expect(fs.statSync(dest).mode & 0o111).not.toBe(0);
|
|
74
|
+
expect(con.text()).toContain('Made tool executable');
|
|
75
|
+
expect(con.text()).toContain('Binary is ready to use');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('does not chmod on Windows', async () => {
|
|
79
|
+
const dest = path.join(tmp, 'tool');
|
|
80
|
+
await withPlatform('win32', () => downloadPackage(fakeGithub('x'), 'https://x/tool', dest));
|
|
81
|
+
expect(con.text()).not.toContain('executable');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('reports and rethrows download failures', async () => {
|
|
85
|
+
const dest = path.join(tmp, 'app.deb');
|
|
86
|
+
await expect(
|
|
87
|
+
downloadPackage(fakeGithub(new Error('HTTP 404')), 'https://x/app.deb', dest)
|
|
88
|
+
).rejects.toThrow('HTTP 404');
|
|
89
|
+
expect(con.text()).toContain('Failed to download app.deb');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('printInstallInstructions', () => {
|
|
94
|
+
let con: ReturnType<typeof captureConsole>;
|
|
95
|
+
beforeEach(() => void (con = captureConsole()));
|
|
96
|
+
afterEach(() => con.restore());
|
|
97
|
+
|
|
98
|
+
const run = async (platform: string, file: string) => {
|
|
99
|
+
await withPlatform(platform, () => printInstallInstructions(`/dl/${file}`, file));
|
|
100
|
+
return con.text();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
test('Windows: exe and msi', async () => {
|
|
104
|
+
expect(await run('win32', 'a.exe')).toContain('Run the executable');
|
|
105
|
+
expect(await run('win32', 'a.msi')).toContain('msiexec /i "/dl/a.msi"');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('Windows: other files print nothing', async () => {
|
|
109
|
+
expect(await run('win32', 'a.zip')).toBe('');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('macOS: dmg and pkg', async () => {
|
|
113
|
+
expect(await run('darwin', 'a.dmg')).toContain('open "/dl/a.dmg"');
|
|
114
|
+
expect(await run('darwin', 'a.pkg')).toContain('sudo installer -pkg "/dl/a.pkg" -target /');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('macOS: other files print nothing', async () => {
|
|
118
|
+
expect(await run('darwin', 'a.zip')).toBe('');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('Linux: deb, rpm, AppImage and bare binaries', async () => {
|
|
122
|
+
expect(await run('linux', 'a.deb')).toContain('sudo dpkg -i "/dl/a.deb"');
|
|
123
|
+
expect(await run('linux', 'a.rpm')).toContain('sudo rpm -i "/dl/a.rpm"');
|
|
124
|
+
expect(await run('linux', 'a.AppImage')).toContain('chmod +x "/dl/a.AppImage"');
|
|
125
|
+
expect(await run('linux', 'bin')).toContain('sudo mv "/dl/bin" /usr/local/bin/');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('Linux: archives print nothing', async () => {
|
|
129
|
+
expect(await run('linux', 'a.tar.gz')).toBe('');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('showPackageMenu', () => {
|
|
134
|
+
test('says so and downloads nothing when no packages are selectable', async () => {
|
|
135
|
+
const con = captureConsole();
|
|
136
|
+
let called = false;
|
|
137
|
+
try {
|
|
138
|
+
await showPackageMenu(
|
|
139
|
+
{ allReleases: [] },
|
|
140
|
+
async () => {
|
|
141
|
+
called = true;
|
|
142
|
+
return '';
|
|
143
|
+
},
|
|
144
|
+
{ os: 'linux', arch: 'x64', platform: 'linux' } as any
|
|
145
|
+
);
|
|
146
|
+
expect(con.text()).toContain('No packages found for download.');
|
|
147
|
+
expect(called).toBe(false);
|
|
148
|
+
expect(buildReleaseChoices([], { os: 'linux' } as any)).toEqual([]);
|
|
149
|
+
} finally {
|
|
150
|
+
con.restore();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { exec } from '../src/utils.ts';
|
|
3
|
+
import { installFakeShell, type FakeShell } from './helpers/fake-shell.ts';
|
|
4
|
+
|
|
5
|
+
describe('exec', () => {
|
|
6
|
+
let fake: FakeShell;
|
|
7
|
+
let errors: string[];
|
|
8
|
+
let origError: typeof console.error;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
errors = [];
|
|
12
|
+
origError = console.error;
|
|
13
|
+
console.error = (...a: unknown[]) => void errors.push(a.join(' '));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
console.error = origError;
|
|
18
|
+
fake?.restore();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('runs the command through the shell', () => {
|
|
22
|
+
fake = installFakeShell();
|
|
23
|
+
exec('tool --flag value');
|
|
24
|
+
expect(fake.commands).toEqual(['tool --flag value']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('swallows failures silently by default', () => {
|
|
28
|
+
fake = installFakeShell({ failing: ['tool'] });
|
|
29
|
+
expect(() => exec('tool')).not.toThrow();
|
|
30
|
+
expect(errors).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('reports the failed command when showError is set', () => {
|
|
34
|
+
fake = installFakeShell({ failing: ['tool'] });
|
|
35
|
+
exec('tool', true);
|
|
36
|
+
expect(errors.join('\n')).toContain('Failed: tool');
|
|
37
|
+
});
|
|
38
|
+
});
|