agentic-workflow-manager 6.4.1 → 6.4.2
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/src/commands/add.js +18 -1
- package/dist/src/commands/init.js +11 -2
- package/dist/src/core/provider-version.js +26 -2
- package/dist/src/index.js +10 -5
- package/dist/src/utils/config.js +17 -0
- package/dist/tests/commands/add.test.js +42 -0
- package/dist/tests/commands/init.test.js +23 -0
- package/dist/tests/core/provider-version.test.js +41 -0
- package/dist/tests/utils/config.test.js +21 -0
- package/package.json +1 -1
package/dist/src/commands/add.js
CHANGED
|
@@ -52,13 +52,30 @@ function runAddBundleCore(options, prefs, bundles, deps = {}) {
|
|
|
52
52
|
console.error(picocolors_1.default.red('No project root found (need a .git/, package.json, or .awm/profile.json here). Run inside a project, or pass --global.'));
|
|
53
53
|
return { code: 1, selectedAgents };
|
|
54
54
|
}
|
|
55
|
+
// `--method` used to be accepted here and silently discarded — this branch
|
|
56
|
+
// hardcoded 'symlink' regardless of what was passed. Since D-001 made bundle
|
|
57
|
+
// names the ONLY thing `add [name]` resolves against, this is the sole live
|
|
58
|
+
// path for any real invocation; the interactive/`--all` path below still has
|
|
59
|
+
// its own (correct) method resolution, but a valid bundle name never reaches
|
|
60
|
+
// it. Found running the issue #55 Windows playbook's WIN-02: `--method copy`
|
|
61
|
+
// on native Windows still produced a Junction, because 'symlink' was the
|
|
62
|
+
// only value that ever actually reached the installer. The no-flag default
|
|
63
|
+
// stays 'symlink' (unchanged) — only the explicit-override case was broken.
|
|
64
|
+
let methodVal = 'symlink';
|
|
65
|
+
if (options.method) {
|
|
66
|
+
if (options.method !== 'symlink' && options.method !== 'copy') {
|
|
67
|
+
console.error(picocolors_1.default.red(`Invalid method "${options.method}". Use: symlink or copy.`));
|
|
68
|
+
return { code: 1, selectedAgents };
|
|
69
|
+
}
|
|
70
|
+
methodVal = options.method;
|
|
71
|
+
}
|
|
55
72
|
let result;
|
|
56
73
|
try {
|
|
57
74
|
result = d.addBundle({
|
|
58
75
|
bundleName: matchedBundle.name,
|
|
59
76
|
bundles,
|
|
60
77
|
agents: selectedAgents,
|
|
61
|
-
method:
|
|
78
|
+
method: methodVal,
|
|
62
79
|
projectRoot: projectRoot ?? cwd,
|
|
63
80
|
scopeOverride,
|
|
64
81
|
});
|
|
@@ -128,8 +128,17 @@ async function runInit(opts = {}) {
|
|
|
128
128
|
// Fires at most once per `awm init` run (native Windows only) — this is
|
|
129
129
|
// the single emission point; `renderReport`/`renderInitOutcome` below do
|
|
130
130
|
// NOT also embed it (that used to triple-fire the same text: once here,
|
|
131
|
-
// once in the "Initial state" render, once in "Final state").
|
|
132
|
-
|
|
131
|
+
// once in the "Initial state" render, once in "Final state"). In `--json`
|
|
132
|
+
// mode it goes to stderr instead of stdout: stdout must stay pure JSON for
|
|
133
|
+
// `awm init --yes --json > init.json` (documented in core-acceptance.md
|
|
134
|
+
// CORE-03) — a stray banner ahead of the `{` broke that contract on every
|
|
135
|
+
// native-Windows `--json` run.
|
|
136
|
+
if (opts.json) {
|
|
137
|
+
(0, paths_1.noteWindowsCaveat)((m) => process.stderr.write(picocolors_1.default.dim(`ℹ ${m}`) + '\n'));
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
(0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
|
|
141
|
+
}
|
|
133
142
|
const cwd = opts.cwd ?? process.cwd();
|
|
134
143
|
const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
|
|
135
144
|
// R2: gate BEFORE anything is read or written — an unsupported provider
|
|
@@ -4,6 +4,7 @@ exports.assertProviderSupported = assertProviderSupported;
|
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
5
|
const providers_1 = require("../providers");
|
|
6
6
|
const versioning_1 = require("./versioning");
|
|
7
|
+
const paths_1 = require("./paths");
|
|
7
8
|
function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
|
|
8
9
|
const provider = (0, providers_1.providerFor)(agent);
|
|
9
10
|
if (!provider.versionCommand || !provider.minimumVersion) {
|
|
@@ -15,17 +16,40 @@ function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
|
|
|
15
16
|
encoding: 'utf8',
|
|
16
17
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
17
18
|
timeout: 5000,
|
|
19
|
+
// Windows can't CreateProcess a `.cmd` shim directly (npm installs
|
|
20
|
+
// `codex` as `codex.cmd`, not `codex.exe`) — execFileSync needs a
|
|
21
|
+
// shell to resolve and run it, or it throws ENOENT even though
|
|
22
|
+
// typing `codex --version` in the same shell works fine. Safe here
|
|
23
|
+
// (unlike sensors.json's `cmd`, core/paths.ts's resolveOnPath):
|
|
24
|
+
// `provider.versionCommand.command`/`args` are hardcoded first-party
|
|
25
|
+
// config (providers/index.ts), never attacker-controlled input.
|
|
26
|
+
// Found running the issue #55 Windows playbook: `awm init -a codex`
|
|
27
|
+
// reported "Codex is not installed" on a machine where it plainly
|
|
28
|
+
// was — `codex --version` worked fine typed directly.
|
|
29
|
+
shell: (0, paths_1.isWindowsNative)(),
|
|
18
30
|
}).toString();
|
|
19
31
|
}
|
|
20
32
|
catch (error) {
|
|
21
|
-
const
|
|
33
|
+
const err = error;
|
|
22
34
|
// `provider.label` y `versionCommand`, no "Codex" literal. Esta funcion es
|
|
23
35
|
// generica sobre AgentTarget desde siempre, pero cada mensaje y el patron de
|
|
24
36
|
// parseo nombraban al unico provider que hoy declara `versionCommand` — el
|
|
25
37
|
// segundo en declararlo habria reportado "Codex no esta instalado" al no
|
|
26
38
|
// encontrar SU binario, y habria fallado a parsear una salida perfectamente
|
|
27
39
|
// valida contra el formato de otro programa.
|
|
28
|
-
|
|
40
|
+
//
|
|
41
|
+
// Two distinct "not found" shapes to catch now that Windows goes through
|
|
42
|
+
// a shell (see `shell: isWindowsNative()` above): without a shell, a
|
|
43
|
+
// missing binary is a spawn-level ENOENT; through cmd.exe, the shell
|
|
44
|
+
// itself spawns fine and the missing command instead surfaces as a
|
|
45
|
+
// non-zero exit with "'codex' is not recognized..." on stderr — no
|
|
46
|
+
// ENOENT anywhere. Missing this second shape was a real regression:
|
|
47
|
+
// windows-latest CI (no codex installed at all) started reporting
|
|
48
|
+
// "version probe failed" instead of "not installed", because the exit
|
|
49
|
+
// code alone doesn't say WHY the shell failed.
|
|
50
|
+
const shellCommandNotFound = (0, paths_1.isWindowsNative)()
|
|
51
|
+
&& /is not recognized as an internal or external command/i.test(String(err.stderr ?? ''));
|
|
52
|
+
if (err.code === 'ENOENT' || shellCommandNotFound) {
|
|
29
53
|
throw new Error(`${provider.label} is not installed or not available on PATH ` +
|
|
30
54
|
`(tried \`${provider.versionCommand.command}\`). Install it, then re-run.`);
|
|
31
55
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -122,7 +122,7 @@ program.command('add [name]')
|
|
|
122
122
|
if (name) {
|
|
123
123
|
const allBundles = (0, bundles_1.discoverAllBundles)();
|
|
124
124
|
const prefs = (0, config_1.getPreferences)();
|
|
125
|
-
const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope }, prefs, allBundles);
|
|
125
|
+
const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope, method: options.method }, prefs, allBundles);
|
|
126
126
|
if (outcome.code !== 0)
|
|
127
127
|
process.exit(outcome.code);
|
|
128
128
|
(0, prompts_1.outro)('Done.');
|
|
@@ -534,12 +534,17 @@ program.command('remove [name]')
|
|
|
534
534
|
targetAgents = agentChoice;
|
|
535
535
|
}
|
|
536
536
|
let scopeVal;
|
|
537
|
-
if (options.scope) {
|
|
538
|
-
|
|
539
|
-
|
|
537
|
+
if (options.scope || options.yes) {
|
|
538
|
+
// Same reasoning as the --agent default just above: `--yes` means ZERO
|
|
539
|
+
// prompts. Without the `options.yes` half of this condition, `awm remove
|
|
540
|
+
// <bundle> --yes` still opened the scope picker and hung any
|
|
541
|
+
// non-interactive caller — the flag promised no-interactive and wasn't.
|
|
542
|
+
const resolved = (0, config_1.resolveScopeOption)(options.scope, prefs.defaultScope);
|
|
543
|
+
if (!resolved.ok) {
|
|
544
|
+
console.error(picocolors_1.default.red(resolved.error));
|
|
540
545
|
process.exit(1);
|
|
541
546
|
}
|
|
542
|
-
scopeVal =
|
|
547
|
+
scopeVal = resolved.scope;
|
|
543
548
|
}
|
|
544
549
|
else {
|
|
545
550
|
const scopeChoice = await (0, prompts_1.select)({
|
package/dist/src/utils/config.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.getPreferences = getPreferences;
|
|
|
9
9
|
exports.loadPreferences = loadPreferences;
|
|
10
10
|
exports.savePreferences = savePreferences;
|
|
11
11
|
exports.enableAgent = enableAgent;
|
|
12
|
+
exports.resolveScopeOption = resolveScopeOption;
|
|
12
13
|
// src/utils/config.ts
|
|
13
14
|
const fs_1 = __importDefault(require("fs"));
|
|
14
15
|
const path_1 = __importDefault(require("path"));
|
|
@@ -158,3 +159,19 @@ function enableAgent(prefs, agent) {
|
|
|
158
159
|
? prefs
|
|
159
160
|
: { ...prefs, enabledAgents: [...prefs.enabledAgents, agent] };
|
|
160
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve `--scope` the same way across every non-interactive command path: an
|
|
164
|
+
* explicit value wins (validated), otherwise fall back to the caller's default
|
|
165
|
+
* instead of prompting. Pulled out of `remove`'s Commander closure (D-006) after
|
|
166
|
+
* `--yes` was found to still open the scope picker with no `--scope` given —
|
|
167
|
+
* `--yes` means zero prompts, and this is the one call site that had drifted
|
|
168
|
+
* from that rule (the sibling --agent default sits right next to it in index.ts).
|
|
169
|
+
*/
|
|
170
|
+
function resolveScopeOption(explicit, fallback) {
|
|
171
|
+
if (explicit === undefined)
|
|
172
|
+
return { ok: true, scope: fallback };
|
|
173
|
+
if (explicit !== 'local' && explicit !== 'global') {
|
|
174
|
+
return { ok: false, error: `Invalid scope "${explicit}". Use: local or global.` };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, scope: explicit };
|
|
177
|
+
}
|
|
@@ -94,3 +94,45 @@ it('awm add demo materializes a real Copilot .instructions.md file end-to-end',
|
|
|
94
94
|
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
95
95
|
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
96
96
|
});
|
|
97
|
+
// Regression for WIN-02 (issue #55 Windows playbook): `--method copy` was
|
|
98
|
+
// silently discarded by this exact function — `runAddBundleCore` hardcoded
|
|
99
|
+
// 'symlink' regardless of what the CLI parsed, so a real Windows machine got
|
|
100
|
+
// a Junction back no matter what `--method` asked for.
|
|
101
|
+
describe('runAddBundleCore --method', () => {
|
|
102
|
+
const claudeCodePrefs = {
|
|
103
|
+
defaultAgent: 'claude-code', enabledAgents: ['claude-code'], installMethod: 'symlink', defaultScope: 'local',
|
|
104
|
+
};
|
|
105
|
+
it('honours an explicit --method copy: a real directory, not a symlink', () => {
|
|
106
|
+
const content = makeContentFixture();
|
|
107
|
+
const projectRoot = makeProjectRoot();
|
|
108
|
+
const bundles = (0, bundles_1.discoverBundles)(content);
|
|
109
|
+
const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', method: 'copy', cwd: projectRoot }, claudeCodePrefs, bundles);
|
|
110
|
+
expect(outcome.code).toBe(0);
|
|
111
|
+
const skillPath = path_1.default.join(projectRoot, '.claude/skills/demo-skill');
|
|
112
|
+
expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(false);
|
|
113
|
+
expect(fs_1.default.readFileSync(path_1.default.join(skillPath, 'SKILL.md'), 'utf8')).toContain('Follow the demo skill body.');
|
|
114
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
115
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
116
|
+
});
|
|
117
|
+
it('still defaults to symlink when --method is omitted (unchanged behavior)', () => {
|
|
118
|
+
const content = makeContentFixture();
|
|
119
|
+
const projectRoot = makeProjectRoot();
|
|
120
|
+
const bundles = (0, bundles_1.discoverBundles)(content);
|
|
121
|
+
const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', cwd: projectRoot }, claudeCodePrefs, bundles);
|
|
122
|
+
expect(outcome.code).toBe(0);
|
|
123
|
+
const skillPath = path_1.default.join(projectRoot, '.claude/skills/demo-skill');
|
|
124
|
+
expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(true);
|
|
125
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
126
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
127
|
+
});
|
|
128
|
+
it('rejects an invalid --method value without touching the filesystem', () => {
|
|
129
|
+
const content = makeContentFixture();
|
|
130
|
+
const projectRoot = makeProjectRoot();
|
|
131
|
+
const bundles = (0, bundles_1.discoverBundles)(content);
|
|
132
|
+
const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', method: 'bogus', cwd: projectRoot }, claudeCodePrefs, bundles);
|
|
133
|
+
expect(outcome.code).toBe(1);
|
|
134
|
+
expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude/skills/demo-skill'))).toBe(false);
|
|
135
|
+
fs_1.default.rmSync(content, { recursive: true, force: true });
|
|
136
|
+
fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -108,6 +108,29 @@ describe('runInit', () => {
|
|
|
108
108
|
const caveatCalls = logSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
|
|
109
109
|
expect(caveatCalls).toHaveLength(0);
|
|
110
110
|
});
|
|
111
|
+
// Regression: `awm init --yes --json > init.json` on native Windows used to
|
|
112
|
+
// write the caveat to stdout via console.log AHEAD of the JSON payload,
|
|
113
|
+
// so the documented core-acceptance.md CORE-03 flow produced a file that
|
|
114
|
+
// wasn't valid JSON (banner text, then `{...}`). The caveat must still
|
|
115
|
+
// reach the operator — it now goes to stderr instead of vanishing.
|
|
116
|
+
it('does not pollute --json stdout with the caveat on native Windows', async () => {
|
|
117
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
118
|
+
const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
119
|
+
const { runInit } = require('../../src/commands/init');
|
|
120
|
+
await runInit({
|
|
121
|
+
cwd: tmpHome,
|
|
122
|
+
yes: true,
|
|
123
|
+
json: true,
|
|
124
|
+
actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
|
|
125
|
+
});
|
|
126
|
+
const written = writeSpy.mock.calls.map((c) => c[0]).join('');
|
|
127
|
+
expect(() => JSON.parse(written)).not.toThrow();
|
|
128
|
+
const caveatOnStdout = logSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
|
|
129
|
+
expect(caveatOnStdout).toHaveLength(0);
|
|
130
|
+
const caveatOnStderr = errSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
|
|
131
|
+
expect(caveatOnStderr).toHaveLength(1);
|
|
132
|
+
errSpy.mockRestore();
|
|
133
|
+
});
|
|
111
134
|
});
|
|
112
135
|
it('returns exit 0 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
|
|
113
136
|
const { runInit } = require('../../src/commands/init');
|
|
@@ -10,6 +10,47 @@ describe('assertProviderSupported', () => {
|
|
|
10
10
|
});
|
|
11
11
|
expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.any(Object));
|
|
12
12
|
});
|
|
13
|
+
// Regression: npm installs `codex` as `codex.cmd` on Windows, and
|
|
14
|
+
// execFileSync can't CreateProcess a `.cmd` shim without a shell — it threw
|
|
15
|
+
// ENOENT here even on a machine where `codex --version` worked fine typed
|
|
16
|
+
// directly. `provider.versionCommand` is hardcoded first-party config
|
|
17
|
+
// (providers/index.ts), never attacker-controlled, so `shell: true` here
|
|
18
|
+
// carries none of the injection risk core/paths.ts's resolveOnPath was
|
|
19
|
+
// built to avoid for sensors.json's user/registry-supplied `cmd`.
|
|
20
|
+
describe('on native Windows', () => {
|
|
21
|
+
const realPlatform = process.platform;
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
|
|
24
|
+
});
|
|
25
|
+
it('runs the version probe through a shell so the .cmd shim resolves', () => {
|
|
26
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
27
|
+
const exec = jest.fn(() => Buffer.from('codex-cli 0.145.0\n'));
|
|
28
|
+
(0, provider_version_1.assertProviderSupported)('codex', exec);
|
|
29
|
+
expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.objectContaining({ shell: true }));
|
|
30
|
+
});
|
|
31
|
+
it('does not use a shell on non-Windows platforms', () => {
|
|
32
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
33
|
+
const exec = jest.fn(() => Buffer.from('codex-cli 0.145.0\n'));
|
|
34
|
+
(0, provider_version_1.assertProviderSupported)('codex', exec);
|
|
35
|
+
expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.objectContaining({ shell: false }));
|
|
36
|
+
});
|
|
37
|
+
// Regression from the fix above: shipping shell:true changed how a
|
|
38
|
+
// GENUINELY missing binary fails. Without a shell it's a spawn-level
|
|
39
|
+
// ENOENT; through cmd.exe the shell itself starts fine and the missing
|
|
40
|
+
// command surfaces as a non-zero exit with this exact stderr text — no
|
|
41
|
+
// ENOENT anywhere. windows-latest CI (no codex installed) caught this:
|
|
42
|
+
// it started reporting "version probe failed" instead of "not
|
|
43
|
+
// installed" the first time shell:true shipped without this branch.
|
|
44
|
+
it('still reports "not installed" when the shell itself says the command is unknown', () => {
|
|
45
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
46
|
+
const shellNotFound = Object.assign(new Error('Command failed: codex --version'), {
|
|
47
|
+
status: 1,
|
|
48
|
+
stderr: Buffer.from("'codex' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n"),
|
|
49
|
+
});
|
|
50
|
+
expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => { throw shellNotFound; }))
|
|
51
|
+
.toThrow('Codex is not installed or not available on PATH');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
13
54
|
it.each(['0.145.1', '0.146.0', '1.0.0'])('accepts stable Codex version %s above the minimum', (version) => {
|
|
14
55
|
expect((0, provider_version_1.assertProviderSupported)('codex', () => Buffer.from(`codex-cli ${version}\n`))).toEqual({ provider: 'codex', version });
|
|
15
56
|
});
|
|
@@ -6,6 +6,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const fs_1 = __importDefault(require("fs"));
|
|
7
7
|
const os_1 = __importDefault(require("os"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const config_1 = require("../../src/utils/config");
|
|
10
|
+
// Regression for the `awm remove <bundle> --yes` gap found running the issue #55
|
|
11
|
+
// Windows playbook (CORE-17): `--yes` skipped the agent prompt but not the scope
|
|
12
|
+
// one, so a supposedly non-interactive removal still hung waiting on a picker.
|
|
13
|
+
// D-006's own stated rule is "`--yes` implica cero prompts" — this closes the one
|
|
14
|
+
// call site that had drifted from it.
|
|
15
|
+
describe('resolveScopeOption', () => {
|
|
16
|
+
it('falls back to the default when nothing explicit was passed (the --yes path)', () => {
|
|
17
|
+
expect((0, config_1.resolveScopeOption)(undefined, 'local')).toEqual({ ok: true, scope: 'local' });
|
|
18
|
+
expect((0, config_1.resolveScopeOption)(undefined, 'global')).toEqual({ ok: true, scope: 'global' });
|
|
19
|
+
});
|
|
20
|
+
it('an explicit valid value wins over the default', () => {
|
|
21
|
+
expect((0, config_1.resolveScopeOption)('global', 'local')).toEqual({ ok: true, scope: 'global' });
|
|
22
|
+
});
|
|
23
|
+
it('rejects a value that is neither local nor global', () => {
|
|
24
|
+
expect((0, config_1.resolveScopeOption)('bogus', 'local')).toEqual({
|
|
25
|
+
ok: false,
|
|
26
|
+
error: 'Invalid scope "bogus". Use: local or global.',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
9
30
|
describe('Preferences Manager', () => {
|
|
10
31
|
let tmpHome;
|
|
11
32
|
let tmpWork;
|