@astryxdesign/cli 0.6.3-canary.d0d49a4 → 0.6.3-canary.dea6813
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/README.md +32 -18
- package/api/build/build.doc.mjs +3 -3
- package/api/build/build.test.mjs +14 -3
- package/api/build/build.type.d.mts +35 -1
- package/api/build/build.type.mjs +24 -3
- package/api/build/help/help.d.mts +8 -5
- package/api/build/help/help.mjs +56 -6
- package/api/component/component.doc.mjs +11 -2
- package/api/component/component.mjs +29 -0
- package/api/component/component.test.mjs +38 -0
- package/api/component/component.type.d.mts +11 -1
- package/api/component/component.type.mjs +8 -1
- package/api/discover/discover.doc.mjs +1 -1
- package/api/gap-report/gap-report.doc.mjs +7 -4
- package/api/init/init.doc.mjs +4 -0
- package/api/init/init.test.mjs +41 -1
- package/api/init/remove/remove.mjs +1 -1
- package/api/init/run/run.mjs +20 -10
- package/api/integration/add-contribution.component-names.test.mjs +120 -0
- package/api/integration/add-contribution.mjs +11 -0
- package/api/integration/add-contribution.test.mjs +80 -0
- package/api/integration/integrationAdd.doc.mjs +7 -4
- package/api/integration/integrationAddComponent.doc.mjs +1 -1
- package/api/integration/validate-integration.mjs +2 -0
- package/api/integration/validate-integration.test.mjs +91 -0
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/json/envelope-types.test.mjs +76 -0
- package/api/json/parseResponse.doc.mjs +2 -2
- package/api/layout/expand/expand.mjs +7 -5
- package/api/layout/expand/expand.path-safety.test.mjs +53 -0
- package/api/layout/grammar/grammar.mjs +2 -1
- package/api/layout/layoutExpand.doc.mjs +1 -1
- package/api/search/search-return-type.test.mjs +54 -0
- package/api/search/search.d.mts +2 -9
- package/api/search/search.doc.mjs +5 -1
- package/api/search/search.mjs +10 -3
- package/api/search/search.test.mjs +34 -0
- package/api/swizzle/copy/copy.mjs +28 -11
- package/api/swizzle/swizzle.doc.mjs +1 -1
- package/api/template/copy/copy.mjs +17 -23
- package/api/template/copy/copy.test.mjs +17 -0
- package/api/template/template.doc.mjs +2 -1
- package/api/theme/add/add.binary.test.mjs +91 -0
- package/api/theme/add/add.mjs +20 -3
- package/api/theme/add/add.staging.test.mjs +66 -0
- package/api/theme/build/build.mjs +114 -37
- package/api/theme/build/build.public-component-vars.test.mjs +1 -1
- package/api/theme/build/build.receipt-doc.test.mjs +111 -0
- package/api/theme/build/font-warning.mjs +3 -3
- package/api/theme/build/font-warning.test.mjs +5 -2
- package/api/theme/palette/generate/generate.mjs +7 -2
- package/api/theme/palette/generate/generate.test.mjs +96 -0
- package/api/theme/palette/generate/generator.mjs +8 -1
- package/api/theme/palette/generate/generator.test.mjs +10 -0
- package/api/theme/template/template.mjs +11 -2
- package/api/theme/template/template.test.mjs +15 -0
- package/api/theme/themeBuild.doc.mjs +7 -4
- package/api/theme/themeTemplate.doc.mjs +4 -1
- package/api/upgrade/_adapter.mjs +27 -4
- package/api/upgrade/list/list.mjs +2 -1
- package/api/upgrade/list/list.test.mjs +73 -0
- package/api/upgrade/status/status.mjs +2 -2
- package/api/upgrade/upgrade.doc.mjs +2 -1
- package/api/upgrade/upgrade.type.d.mts +4 -0
- package/api/upgrade/upgrade.type.mjs +1 -0
- package/assets/codemods/term-log.mjs +32 -8
- package/assets/codemods/term-log.test.mjs +19 -1
- package/assets/codemods/transforms/v0.6.0/__tests__/next-codemods.test.mjs +47 -0
- package/assets/codemods/transforms/v0.6.0/rename-resizable-pixel-bounds.mjs +57 -10
- package/assets/templates/pages/table-tree/page.tsx +1704 -0
- package/assets/templates/pages/table-tree/template.doc.mjs +12 -0
- package/authoring/config/config.doc.mjs +1 -1
- package/authoring/config/debug-composition.test.mjs +92 -0
- package/authoring/config/type.ts +15 -3
- package/clients/cli/command-load-failure.test.mjs +83 -0
- package/clients/cli/commands/blog.mjs +23 -8
- package/clients/cli/commands/blog.test.mjs +42 -1
- package/clients/cli/commands/build-theme.ascii-output.test.mjs +161 -0
- package/clients/cli/commands/build-theme.flag-docs.test.mjs +110 -0
- package/clients/cli/commands/build-theme.mjs +5 -5
- package/clients/cli/commands/build-theme.path-safety.test.mjs +86 -1
- package/clients/cli/commands/build-theme.variants.test.mjs +76 -0
- package/clients/cli/commands/build.doc.mjs +4 -1
- package/clients/cli/commands/build.exit-codes-doc.test.mjs +50 -0
- package/clients/cli/commands/build.mjs +32 -47
- package/clients/cli/commands/build.playbook.test.mjs +75 -0
- package/clients/cli/commands/build.text-fields.test.mjs +40 -0
- package/clients/cli/commands/component/index.mjs +2 -7
- package/clients/cli/commands/component-package.test.mjs +18 -0
- package/clients/cli/commands/component-resolution.test.mjs +21 -0
- package/clients/cli/commands/component.test.mjs +19 -0
- package/clients/cli/commands/detail-levels.test.mjs +2 -2
- package/clients/cli/commands/discover.broken-integration.test.mjs +48 -0
- package/clients/cli/commands/discover.components-flag.test.mjs +97 -0
- package/clients/cli/commands/discover.doc.mjs +4 -2
- package/clients/cli/commands/discover.mjs +3 -3
- package/clients/cli/commands/discover.text-projection.test.mjs +103 -0
- package/clients/cli/commands/doctor-integration.package-json.test.mjs +53 -0
- package/clients/cli/commands/doctor.mjs +6 -16
- package/clients/cli/commands/doctor.test.mjs +42 -0
- package/clients/cli/commands/gap-report.doc.mjs +16 -5
- package/clients/cli/commands/gap-report.test.mjs +72 -0
- package/clients/cli/commands/hook/index.mjs +7 -17
- package/clients/cli/commands/hook.text-projection.test.mjs +45 -0
- package/clients/cli/commands/init.doc.mjs +19 -8
- package/clients/cli/commands/init.flag-help.test.mjs +153 -0
- package/clients/cli/commands/integration-add.controls.test.mjs +132 -0
- package/clients/cli/commands/integration-add.doc.mjs +21 -5
- package/clients/cli/commands/interactive-guard.test.mjs +101 -24
- package/clients/cli/commands/json-contract.test.mjs +33 -0
- package/clients/cli/commands/layout-check.doc.mjs +14 -3
- package/clients/cli/commands/layout-expand.doc.mjs +21 -4
- package/clients/cli/commands/layout.doc.mjs +2 -2
- package/clients/cli/commands/layout.mjs +21 -9
- package/clients/cli/commands/layout.path-help.test.mjs +33 -0
- package/clients/cli/commands/layout.stdin-cap.test.mjs +47 -0
- package/clients/cli/commands/layout.text-fields.test.mjs +39 -0
- package/clients/cli/commands/no-prompt-wording.test.mjs +94 -0
- package/clients/cli/commands/search.doc.mjs +6 -3
- package/clients/cli/commands/search.mjs +17 -7
- package/clients/cli/commands/search.test.mjs +75 -0
- package/clients/cli/commands/swizzle.doc.mjs +2 -1
- package/clients/cli/commands/swizzle.path-safety.test.mjs +42 -0
- package/clients/cli/commands/template.doc.mjs +28 -6
- package/clients/cli/commands/template.flag-help.test.mjs +117 -0
- package/clients/cli/commands/template.path-help.test.mjs +40 -0
- package/clients/cli/commands/theme-add.doc.mjs +2 -1
- package/clients/cli/commands/theme-build.doc.mjs +7 -6
- package/clients/cli/commands/theme-palette-generate.test.mjs +19 -0
- package/clients/cli/commands/theme-template.behavior.test.mjs +12 -0
- package/clients/cli/commands/theme-template.doc.mjs +1 -1
- package/clients/cli/commands/upgrade.ascii-output.test.mjs +87 -0
- package/clients/cli/commands/upgrade.doc.mjs +17 -6
- package/clients/cli/commands/upgrade.flag-help.test.mjs +188 -0
- package/clients/cli/commands/upgrade.hook-output.test.mjs +88 -0
- package/clients/cli/index.mjs +13 -30
- package/clients/cli/latest-version-env.test.mjs +50 -0
- package/clients/cli/lib/cli-error.test.mjs +7 -0
- package/clients/cli/lib/component-format.mjs +9 -9
- package/clients/cli/lib/component-format.test.mjs +1 -1
- package/clients/cli/lib/define-command.mjs +32 -6
- package/clients/cli/lib/doc-text-ascii.test.mjs +82 -0
- package/clients/cli/lib/exit-codes.test.mjs +97 -0
- package/clients/cli/lib/hook-format.mjs +5 -5
- package/clients/cli/lib/json-shim.mjs +38 -2
- package/clients/cli/lib/json-shim.test.mjs +83 -0
- package/clients/cli/lib/manifest.d.ts +2 -0
- package/clients/cli/lib/manifest.mjs +22 -0
- package/clients/cli/lib/manifest.test.mjs +17 -0
- package/foundation/agent-docs/agent-docs.d.mts +4 -0
- package/foundation/agent-docs/agent-docs.mjs +69 -3
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +266 -4
- package/foundation/config/integration-debug.test.mjs +28 -3
- package/foundation/config/project.mjs +123 -9
- package/foundation/config/project.test.mjs +126 -0
- package/foundation/discovery/template-adapter.d.mts +14 -5
- package/foundation/discovery/template-adapter.fixture-refs.test.mjs +248 -0
- package/foundation/discovery/template-adapter.integration-isolation.test.mjs +94 -0
- package/foundation/discovery/template-adapter.mjs +292 -25
- package/foundation/discovery/theming-targets.test.mjs +4 -0
- package/foundation/fs/module-loader.d.mts +1 -0
- package/foundation/fs/module-loader.mjs +50 -1
- package/foundation/fs/module-loader.stdout.test.mjs +332 -0
- package/foundation/fs/path-safety.d.mts +3 -2
- package/foundation/fs/path-safety.mjs +49 -19
- package/foundation/fs/path-safety.test.mjs +50 -0
- package/foundation/fs/publish-file-hardlink-unavailable.test.mjs +129 -94
- package/foundation/integrations/autolink.mjs +6 -1
- package/foundation/response/base.d.ts +8 -4
- package/foundation/response/error-codes.doc.mjs +1 -2
- package/foundation/response/error-codes.mjs +1 -1
- package/foundation/response/error-codes.test.mjs +83 -0
- package/foundation/response/json-contract.test.mjs +11 -0
- package/foundation/response/json.d.mts +4 -2
- package/foundation/response/json.mjs +8 -10
- package/foundation/response/response-types.doc.mjs +3 -3
- package/foundation/xle/expand.d.mts +2 -0
- package/foundation/xle/expand.mjs +3 -2
- package/foundation/xle/expand.test.mjs +54 -0
- package/package.json +9 -9
- package/clients/cli/lib/update-check.mjs +0 -83
- package/clients/cli/lib/update-check.test.mjs +0 -137
- package/clients/cli/update-hint-commands.test.mjs +0 -54
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Path-safety tests for the `layout.expand` write. The file written, not
|
|
5
|
+
* only the directory it lands in, must stay inside the project root.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {describe, it, expect, beforeAll} from 'vitest';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as os from 'node:os';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import {layoutExpand} from '../layout.mjs';
|
|
13
|
+
import {buildRegistry} from '../../../foundation/xle/registry.mjs';
|
|
14
|
+
|
|
15
|
+
const SLOW = 30_000;
|
|
16
|
+
|
|
17
|
+
// The registry imports every component doc on first use; warm it once.
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
await buildRegistry();
|
|
20
|
+
}, 120_000);
|
|
21
|
+
|
|
22
|
+
describe('layout.expand — write confinement', () => {
|
|
23
|
+
it('rejects a directory target whose generated file is a symlink leading outside cwd', async () => {
|
|
24
|
+
// Inside the workspace so @astryxdesign/core resolves.
|
|
25
|
+
const cwd = fs.mkdtempSync(path.join(process.cwd(), '.xle-confine-test-'));
|
|
26
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'xle-confine-outside-'));
|
|
27
|
+
try {
|
|
28
|
+
const victim = path.join(outside, 'victim.tsx');
|
|
29
|
+
fs.writeFileSync(victim, 'OUTSIDE');
|
|
30
|
+
fs.mkdirSync(path.join(cwd, 'out'));
|
|
31
|
+
fs.symlinkSync(victim, path.join(cwd, 'out', 'GeneratedLayout.tsx'));
|
|
32
|
+
|
|
33
|
+
await expect(
|
|
34
|
+
layoutExpand('V > B', {targetPath: './out', cwd}),
|
|
35
|
+
).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
|
|
36
|
+
expect(fs.readFileSync(victim, 'utf-8')).toBe('OUTSIDE');
|
|
37
|
+
} finally {
|
|
38
|
+
fs.rmSync(cwd, {recursive: true, force: true});
|
|
39
|
+
fs.rmSync(outside, {recursive: true, force: true});
|
|
40
|
+
}
|
|
41
|
+
}, SLOW);
|
|
42
|
+
|
|
43
|
+
it('still writes <Name>.tsx into a plain directory target', async () => {
|
|
44
|
+
const cwd = fs.mkdtempSync(path.join(process.cwd(), '.xle-confine-test-'));
|
|
45
|
+
try {
|
|
46
|
+
const result = await layoutExpand('V > B', {targetPath: './out', name: 'Demo', cwd});
|
|
47
|
+
expect(result.data.written).toBe(path.join('out', 'Demo.tsx'));
|
|
48
|
+
expect(fs.existsSync(path.join(cwd, 'out', 'Demo.tsx'))).toBe(true);
|
|
49
|
+
} finally {
|
|
50
|
+
fs.rmSync(cwd, {recursive: true, force: true});
|
|
51
|
+
}
|
|
52
|
+
}, SLOW);
|
|
53
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {buildRegistry} from '../../../foundation/xle/registry.mjs';
|
|
13
|
+
import {MAX_REPEAT} from '../../../foundation/xle/expand.mjs';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* `astryx layout grammar` — the agent cheatsheet, with the alias table
|
|
@@ -50,7 +51,7 @@ NODE ANATOMY Name#id.enum"payload"[attrs]{hint}*N > children
|
|
|
50
51
|
.enum unique enum value of any prop: Bd.success Tx.lg B.primary
|
|
51
52
|
"payload" primary text prop (label/title/heading) or text child: TI"Email" B"Save"
|
|
52
53
|
{hint} kebab-case template/component reference (see TEMPLATE REFERENCING) — NEVER text
|
|
53
|
-
*N / xN repeat (use $ for the counter: Tk"item-$"*3)
|
|
54
|
+
*N / xN repeat, at most ${MAX_REPEAT} copies (use $ for the counter: Tk"item-$"*3)
|
|
54
55
|
trailing ! initial selection for scaffolded state: Tab"Overview"!
|
|
55
56
|
|
|
56
57
|
ATTRS [...] (outline: bare tokens after the name, no brackets)
|
|
@@ -36,7 +36,7 @@ export const doc = {
|
|
|
36
36
|
name: 'options.targetPath',
|
|
37
37
|
type: 'string',
|
|
38
38
|
description:
|
|
39
|
-
'Write the generated TSX here (validated to stay within cwd). A
|
|
39
|
+
'Write the generated TSX here (validated to stay within cwd). A path that ends in .tsx, .ts, .jsx, .js, .mjs, .cjs, .css, .scss, .json, .md or .html is used as-is; any other path is a directory that gets <name>.tsx. An existing file is replaced. Omit to return the code without writing.',
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
name: 'options.form',
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file The declared return type of `search()` is the canonical SearchResponse.
|
|
5
|
+
* Consumers of `@astryxdesign/cli/api` type-check against it; an `object` entry
|
|
6
|
+
* type erases every result field from their view.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {createRequire} from 'node:module';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import {fileURLToPath} from 'node:url';
|
|
12
|
+
import {describe, expect, it} from 'vitest';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ts = require('typescript');
|
|
16
|
+
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const SEARCH = path.join(HERE, 'search.mjs');
|
|
19
|
+
const TYPES = path.join(HERE, 'search.type.mjs');
|
|
20
|
+
|
|
21
|
+
describe('search() declared return type', () => {
|
|
22
|
+
it('types each result as the canonical SearchResultEntry', () => {
|
|
23
|
+
const program = ts.createProgram([SEARCH, TYPES], {
|
|
24
|
+
allowJs: true,
|
|
25
|
+
noEmit: true,
|
|
26
|
+
skipLibCheck: true,
|
|
27
|
+
types: [],
|
|
28
|
+
module: ts.ModuleKind.NodeNext,
|
|
29
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
30
|
+
target: ts.ScriptTarget.ES2022,
|
|
31
|
+
});
|
|
32
|
+
const checker = program.getTypeChecker();
|
|
33
|
+
const source = program.getSourceFile(SEARCH);
|
|
34
|
+
/** @param {string} file @param {string} name */
|
|
35
|
+
const exportOf = (file, name) =>
|
|
36
|
+
checker
|
|
37
|
+
.getExportsOfModule(checker.getSymbolAtLocation(program.getSourceFile(file)))
|
|
38
|
+
.find((/** @type {any} */ s) => s.name === name);
|
|
39
|
+
/** @param {any} type @param {string} name */
|
|
40
|
+
const prop = (type, name) =>
|
|
41
|
+
checker.getTypeOfSymbolAtLocation(type.getProperty(name), source);
|
|
42
|
+
/** @param {any} type */
|
|
43
|
+
const names = type => type.getProperties().map((/** @type {any} */ p) => p.name).sort();
|
|
44
|
+
|
|
45
|
+
const fn = checker.getTypeOfSymbolAtLocation(exportOf(SEARCH, 'search'), source);
|
|
46
|
+
const response = checker.getAwaitedType(fn.getCallSignatures()[0].getReturnType());
|
|
47
|
+
const data = prop(response, 'data');
|
|
48
|
+
expect(names(data)).toEqual(['matchCount', 'query', 'results']);
|
|
49
|
+
const entry = checker.getIndexTypeOfType(prop(data, 'results'), ts.IndexKind.Number);
|
|
50
|
+
const canonical = checker.getDeclaredTypeOfSymbol(exportOf(TYPES, 'SearchResultEntry'));
|
|
51
|
+
expect(names(canonical)).toContain('command');
|
|
52
|
+
expect(names(entry)).toEqual(names(canonical));
|
|
53
|
+
}, 30_000);
|
|
54
|
+
});
|
package/api/search/search.d.mts
CHANGED
|
@@ -68,20 +68,13 @@ export function scoreCandidate(term: string, { name, keywords, weakKeywords, des
|
|
|
68
68
|
* @param {string} [options.cwd]
|
|
69
69
|
* @param {'component'|'hook'|'doc'|'template'} [options.type] - Restrict to one domain.
|
|
70
70
|
* @param {number} [options.limit] - Max results (default 20).
|
|
71
|
-
* @returns {Promise<
|
|
71
|
+
* @returns {Promise<import('./search.type.mjs').SearchResponse>}
|
|
72
72
|
*/
|
|
73
73
|
export function search(query: string, options?: {
|
|
74
74
|
cwd?: string | undefined;
|
|
75
75
|
type?: "component" | "hook" | "doc" | "template" | undefined;
|
|
76
76
|
limit?: number | undefined;
|
|
77
|
-
}): Promise<
|
|
78
|
-
type: "search";
|
|
79
|
-
data: {
|
|
80
|
-
query: string;
|
|
81
|
-
matchCount: number;
|
|
82
|
-
results: Array<object>;
|
|
83
|
-
};
|
|
84
|
-
}>;
|
|
77
|
+
}): Promise<import("./search.type.mjs").SearchResponse>;
|
|
85
78
|
/** Valid domain filters for `--type`. */
|
|
86
79
|
export const SEARCH_DOMAINS: string[];
|
|
87
80
|
/**
|
|
@@ -53,7 +53,7 @@ export const doc = {
|
|
|
53
53
|
{
|
|
54
54
|
type: 'search',
|
|
55
55
|
description:
|
|
56
|
-
'The query echoed back, `matchCount` (how many candidates matched in total, before `limit`), plus a ranked SearchResultEntry[] bounded by `limit
|
|
56
|
+
'The query echoed back, `matchCount` (how many candidates matched in total, before `limit`), plus a ranked SearchResultEntry[] bounded by `limit`: each has domain, name, score, reason, description, and follow-up command, plus `import` for components and hooks, `title` for docs, and `displayName` and `kind` for templates.',
|
|
57
57
|
},
|
|
58
58
|
],
|
|
59
59
|
throws: [
|
|
@@ -61,6 +61,10 @@ export const doc = {
|
|
|
61
61
|
code: 'ERR_INVALID_ARGUMENT',
|
|
62
62
|
when: 'the query is empty, --type is unknown, or --limit is not a positive integer',
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
code: 'ERR_CORE_NOT_FOUND',
|
|
66
|
+
when: '@astryxdesign/core cannot be found from the cwd',
|
|
67
|
+
},
|
|
64
68
|
],
|
|
65
69
|
examples: [
|
|
66
70
|
{label: 'Find a component', code: "const r = await search('button');"},
|
package/api/search/search.mjs
CHANGED
|
@@ -884,7 +884,7 @@ function toResult(c, score, reason, matchedTerms, queryTerms) {
|
|
|
884
884
|
* @param {string} [options.cwd]
|
|
885
885
|
* @param {'component'|'hook'|'doc'|'template'} [options.type] - Restrict to one domain.
|
|
886
886
|
* @param {number} [options.limit] - Max results (default 20).
|
|
887
|
-
* @returns {Promise<
|
|
887
|
+
* @returns {Promise<import('./search.type.mjs').SearchResponse>}
|
|
888
888
|
*/
|
|
889
889
|
export async function search(query, options = {}) {
|
|
890
890
|
const {cwd = process.cwd(), type, limit = 20} = options;
|
|
@@ -921,7 +921,11 @@ export async function search(query, options = {}) {
|
|
|
921
921
|
|
|
922
922
|
const coreDir = findCoreDir(cwd);
|
|
923
923
|
if (!coreDir) {
|
|
924
|
-
throw new AstryxError(
|
|
924
|
+
throw new AstryxError(
|
|
925
|
+
'Could not find @astryxdesign/core package',
|
|
926
|
+
undefined,
|
|
927
|
+
ERROR_CODES.ERR_CORE_NOT_FOUND,
|
|
928
|
+
);
|
|
925
929
|
}
|
|
926
930
|
|
|
927
931
|
// Gather candidates from each requested domain in parallel.
|
|
@@ -971,7 +975,10 @@ export async function search(query, options = {}) {
|
|
|
971
975
|
data: {
|
|
972
976
|
query: String(query).trim(),
|
|
973
977
|
matchCount: scored.length,
|
|
974
|
-
|
|
978
|
+
// toResult gives every domain its command and domain fields.
|
|
979
|
+
results: /** @type {import('./search.type.mjs').SearchResultEntry[]} */ (
|
|
980
|
+
limited
|
|
981
|
+
),
|
|
975
982
|
},
|
|
976
983
|
};
|
|
977
984
|
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import {describe, it, expect} from 'vitest';
|
|
23
23
|
import * as fs from 'node:fs';
|
|
24
|
+
import * as os from 'node:os';
|
|
24
25
|
import * as path from 'node:path';
|
|
25
26
|
import {fileURLToPath} from 'node:url';
|
|
26
27
|
import {
|
|
@@ -70,6 +71,28 @@ describe('search leaf — envelope + ranking', () => {
|
|
|
70
71
|
}, SLOW);
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
describe('search leaf — per-domain result fields', () => {
|
|
75
|
+
it('carries import for components and hooks, title for docs, displayName and kind for templates', async () => {
|
|
76
|
+
const r = await search('theme', {cwd, limit: 60});
|
|
77
|
+
expect(new Set(r.data.results.map(res => res.domain))).toEqual(
|
|
78
|
+
new Set(SEARCH_DOMAINS),
|
|
79
|
+
);
|
|
80
|
+
for (const res of r.data.results) {
|
|
81
|
+
expect(typeof res.command).toBe('string');
|
|
82
|
+
expect(typeof res.description).toBe('string');
|
|
83
|
+
if (res.domain === 'component' || res.domain === 'hook') {
|
|
84
|
+
expect(res.import).toMatch(/\S/);
|
|
85
|
+
} else if (res.domain === 'doc') {
|
|
86
|
+
expect(res.title).toMatch(/\S/);
|
|
87
|
+
expect(res.command).toBe(`astryx docs ${res.name}`);
|
|
88
|
+
} else {
|
|
89
|
+
expect(res.displayName).toMatch(/\S/);
|
|
90
|
+
expect(['page', 'block']).toContain(res.kind);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}, SLOW);
|
|
94
|
+
});
|
|
95
|
+
|
|
73
96
|
describe('search leaf — matchCount is the total, not the cap', () => {
|
|
74
97
|
it('reports every match while `results` stays bounded by the limit', async () => {
|
|
75
98
|
// The regression: `matchCount` used to be `results.length`, so a query
|
|
@@ -160,6 +183,17 @@ describe('search leaf — error paths (pinned)', () => {
|
|
|
160
183
|
search('button', {cwd, type: /** @type {any} */ ('bogus')}),
|
|
161
184
|
).rejects.toMatchObject({code: 'ERR_INVALID_ARGUMENT'});
|
|
162
185
|
}, SLOW);
|
|
186
|
+
|
|
187
|
+
it('throws ERR_CORE_NOT_FOUND when @astryxdesign/core cannot be found', async () => {
|
|
188
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-search-no-core-'));
|
|
189
|
+
try {
|
|
190
|
+
await expect(search('button', {cwd: empty})).rejects.toMatchObject({
|
|
191
|
+
code: 'ERR_CORE_NOT_FOUND',
|
|
192
|
+
});
|
|
193
|
+
} finally {
|
|
194
|
+
fs.rmSync(empty, {recursive: true, force: true});
|
|
195
|
+
}
|
|
196
|
+
}, SLOW);
|
|
163
197
|
});
|
|
164
198
|
|
|
165
199
|
describe('search leaf — limit validation (API matches the CLI contract)', () => {
|
|
@@ -159,6 +159,24 @@ function resolveOwners(coreDir, loadedIntegrations, name, coreIssuesUrl) {
|
|
|
159
159
|
return owners;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* `assertWithin`, with an escape reported as ERR_PATH_TRAVERSAL.
|
|
164
|
+
* @param {string} target
|
|
165
|
+
* @param {string} cwd
|
|
166
|
+
* @param {{allowAbsolute?: boolean, label: string}} options
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
function confineWrite(target, cwd, options) {
|
|
170
|
+
try {
|
|
171
|
+
return assertWithin(target, cwd, options);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err instanceof PathSafetyError) {
|
|
174
|
+
throw new AstryxError(err.message, [], ERROR_CODES.ERR_PATH_TRAVERSAL);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
/** @param {string} file */
|
|
163
181
|
function isExcludedFromCopy(file) {
|
|
164
182
|
return (
|
|
@@ -243,23 +261,22 @@ export async function swizzleCopy(component, options = {}) {
|
|
|
243
261
|
|
|
244
262
|
const componentDir = owner.sourceDir;
|
|
245
263
|
|
|
246
|
-
// Path-safety:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
outputBase = assertWithin(output, cwd, {label: 'output directory'});
|
|
250
|
-
} catch (err) {
|
|
251
|
-
if (err instanceof PathSafetyError) {
|
|
252
|
-
throw new AstryxError(err.message, [], ERROR_CODES.ERR_PATH_TRAVERSAL);
|
|
253
|
-
}
|
|
254
|
-
throw err;
|
|
255
|
-
}
|
|
264
|
+
// Path-safety: every write target resolves inside cwd, not just --output. An
|
|
265
|
+
// existing component directory or file may be a symlink out of cwd.
|
|
266
|
+
const outputBase = confineWrite(output, cwd, {label: 'output directory'});
|
|
256
267
|
const outputDir = path.join(outputBase, dirName);
|
|
257
268
|
|
|
258
|
-
// Pre-flight overwrite
|
|
269
|
+
// Pre-flight path and overwrite checks before any mkdir/writeFile.
|
|
259
270
|
const sourceFiles = fs.readdirSync(componentDir).filter(file => {
|
|
260
271
|
if (isExcludedFromCopy(file)) return false;
|
|
261
272
|
return fs.statSync(path.join(componentDir, file)).isFile();
|
|
262
273
|
});
|
|
274
|
+
for (const file of sourceFiles) {
|
|
275
|
+
confineWrite(path.join(outputDir, file), cwd, {
|
|
276
|
+
allowAbsolute: true,
|
|
277
|
+
label: 'output file',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
263
280
|
const existingFiles = sourceFiles.filter(f =>
|
|
264
281
|
fs.existsSync(path.join(outputDir, f)),
|
|
265
282
|
);
|
|
@@ -80,7 +80,7 @@ export const doc = {
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
code: 'ERR_PATH_TRAVERSAL',
|
|
83
|
-
when: 'the component name contains a path separator or traversal, or output resolves outside cwd',
|
|
83
|
+
when: 'the component name contains a path separator or traversal, output resolves outside cwd, or an existing output file or directory is a symlink that resolves outside cwd',
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
code: 'ERR_UNKNOWN_COMPONENT',
|
|
@@ -36,13 +36,22 @@ export function templateCopy(match, {targetPath, cwd, overwrite = false}) {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
// If targetPath looks like a file (e.g. `./foo.tsx`), write directly to
|
|
40
|
+
// it. Previously this path was treated as a directory and the file was
|
|
41
|
+
// written as `./foo.tsx/page.tsx`, which is wrong and surprising.
|
|
42
|
+
const fileTarget = isFilePathArg(targetPath)
|
|
43
|
+
? targetPath
|
|
44
|
+
: path.join(
|
|
45
|
+
targetPath,
|
|
46
|
+
match.type === 'block' ? path.basename(match.filePath) : 'page.tsx',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Path-safety: the guard sees the file that will be written, not only its
|
|
50
|
+
// directory — a symlink at that name would otherwise carry the write
|
|
51
|
+
// outside the project root. Runs BEFORE any mkdir/write.
|
|
52
|
+
let outputFilePath;
|
|
44
53
|
try {
|
|
45
|
-
|
|
54
|
+
outputFilePath = assertWithin(fileTarget, cwd, {
|
|
46
55
|
label: 'template target path',
|
|
47
56
|
});
|
|
48
57
|
} catch (err) {
|
|
@@ -55,23 +64,8 @@ export function templateCopy(match, {targetPath, cwd, overwrite = false}) {
|
|
|
55
64
|
}
|
|
56
65
|
throw err;
|
|
57
66
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// it. Previously this path was treated as a directory and the file was
|
|
61
|
-
// written as `./foo.tsx/page.tsx`, which is wrong and surprising.
|
|
62
|
-
let outputDir;
|
|
63
|
-
let outputFileName;
|
|
64
|
-
let outputFilePath;
|
|
65
|
-
if (isFilePathArg(targetPath)) {
|
|
66
|
-
outputDir = path.dirname(resolvedTarget);
|
|
67
|
-
outputFileName = path.basename(resolvedTarget);
|
|
68
|
-
outputFilePath = resolvedTarget;
|
|
69
|
-
} else {
|
|
70
|
-
outputDir = resolvedTarget;
|
|
71
|
-
outputFileName =
|
|
72
|
-
match.type === 'block' ? path.basename(match.filePath) : 'page.tsx';
|
|
73
|
-
outputFilePath = path.join(outputDir, outputFileName);
|
|
74
|
-
}
|
|
67
|
+
const outputDir = path.dirname(outputFilePath);
|
|
68
|
+
const outputFileName = path.basename(outputFilePath);
|
|
75
69
|
|
|
76
70
|
// Refuse to clobber an existing file unless the caller opts in. The CLI has
|
|
77
71
|
// its own pre-flight collision message, but the API is a public surface
|
|
@@ -48,4 +48,21 @@ describe('template.copy — overwrite + path safety', () => {
|
|
|
48
48
|
).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
|
|
49
49
|
expect(fs.existsSync(path.join(dir, '..', 'escape.tsx'))).toBe(false);
|
|
50
50
|
}, SLOW);
|
|
51
|
+
|
|
52
|
+
it('rejects a directory target whose page.tsx is a symlink leading outside cwd', async () => {
|
|
53
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'tmpl-copy-outside-'));
|
|
54
|
+
try {
|
|
55
|
+
const victim = path.join(outside, 'victim.tsx');
|
|
56
|
+
fs.writeFileSync(victim, 'OUTSIDE');
|
|
57
|
+
fs.mkdirSync(path.join(dir, 'dest'));
|
|
58
|
+
fs.symlinkSync(victim, path.join(dir, 'dest', 'page.tsx'));
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
template('blank', {targetPath: './dest', overwrite: true, cwd: dir}),
|
|
62
|
+
).rejects.toMatchObject({code: 'ERR_PATH_TRAVERSAL'});
|
|
63
|
+
expect(fs.readFileSync(victim, 'utf-8')).toBe('OUTSIDE');
|
|
64
|
+
} finally {
|
|
65
|
+
fs.rmSync(outside, {recursive: true, force: true});
|
|
66
|
+
}
|
|
67
|
+
}, SLOW);
|
|
51
68
|
});
|
|
@@ -75,7 +75,8 @@ export const doc = {
|
|
|
75
75
|
name: 'options.targetPath',
|
|
76
76
|
type: 'string',
|
|
77
77
|
description:
|
|
78
|
-
'Destination (relative to cwd) to scaffold the template into. Its presence switches a named lookup into a copy.'
|
|
78
|
+
'Destination (relative to cwd) to scaffold the template into. Its presence switches a named lookup into a copy. ' +
|
|
79
|
+
'A path that ends in .tsx, .ts, .jsx, .js, .mjs, .cjs, .css, .scss, .json, .md or .html is the file to write; any other path is a directory, which receives page.tsx for a page template or the block\'s own file name for a block.',
|
|
79
80
|
},
|
|
80
81
|
{
|
|
81
82
|
name: 'options.overwrite',
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import {themeAdd} from './add.mjs';
|
|
8
|
+
|
|
9
|
+
const HEADER = '// Copyright (c) Meta Platforms, Inc. and affiliates.\n\n';
|
|
10
|
+
|
|
11
|
+
let tmpDir;
|
|
12
|
+
|
|
13
|
+
/** Every byte value, so any lossy text decoding shows up as a changed file. */
|
|
14
|
+
const FONT_BYTES = Buffer.concat([
|
|
15
|
+
Buffer.from('wOF2'),
|
|
16
|
+
Buffer.from(Array.from({length: 256}, (_, byte) => byte)),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-themeadd-binary-'));
|
|
21
|
+
fs.writeFileSync(
|
|
22
|
+
path.join(tmpDir, 'package.json'),
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
name: 'consumer',
|
|
25
|
+
dependencies: {'@acme/themes': '^1.0.0'},
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
const packageDir = path.join(tmpDir, 'node_modules', '@acme', 'themes');
|
|
29
|
+
const themeDir = path.join(packageDir, 'themes', 'ocean');
|
|
30
|
+
fs.mkdirSync(path.join(themeDir, 'fonts'), {recursive: true});
|
|
31
|
+
fs.writeFileSync(
|
|
32
|
+
path.join(packageDir, 'package.json'),
|
|
33
|
+
JSON.stringify({name: '@acme/themes', version: '1.0.0'}),
|
|
34
|
+
);
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
path.join(packageDir, 'astryx.integration.mjs'),
|
|
37
|
+
"export default {themes: './themes'};\n",
|
|
38
|
+
);
|
|
39
|
+
fs.writeFileSync(
|
|
40
|
+
path.join(packageDir, 'themes', 'manifest.json'),
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
version: 1,
|
|
43
|
+
themes: [
|
|
44
|
+
{
|
|
45
|
+
slug: 'ocean',
|
|
46
|
+
displayName: 'Ocean',
|
|
47
|
+
description: 'Integration theme that ships a font.',
|
|
48
|
+
maintained: true,
|
|
49
|
+
entry: 'oceanTheme.ts',
|
|
50
|
+
exportName: 'oceanTheme',
|
|
51
|
+
files: ['oceanTheme.ts', 'fonts/ocean.woff2'],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
fs.writeFileSync(
|
|
57
|
+
path.join(themeDir, 'oceanTheme.ts'),
|
|
58
|
+
`${HEADER}export const oceanTheme = {};\n`,
|
|
59
|
+
);
|
|
60
|
+
fs.writeFileSync(path.join(themeDir, 'fonts', 'ocean.woff2'), FONT_BYTES);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('themeAdd copies every listed file', () => {
|
|
68
|
+
it('copies a binary file byte for byte', async () => {
|
|
69
|
+
const result = await themeAdd('ocean', {
|
|
70
|
+
cwd: tmpDir,
|
|
71
|
+
package: '@acme/themes',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.data.files).toEqual(['oceanTheme.ts', 'fonts/ocean.woff2']);
|
|
75
|
+
const copied = fs.readFileSync(
|
|
76
|
+
path.join(tmpDir, 'src', 'themes', 'ocean', 'fonts', 'ocean.woff2'),
|
|
77
|
+
);
|
|
78
|
+
expect(copied.equals(FONT_BYTES)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('still strips the repo header from a text source', async () => {
|
|
82
|
+
await themeAdd('ocean', {cwd: tmpDir, package: '@acme/themes'});
|
|
83
|
+
|
|
84
|
+
expect(
|
|
85
|
+
fs.readFileSync(
|
|
86
|
+
path.join(tmpDir, 'src', 'themes', 'ocean', 'oceanTheme.ts'),
|
|
87
|
+
'utf-8',
|
|
88
|
+
),
|
|
89
|
+
).toBe('export const oceanTheme = {};\n');
|
|
90
|
+
});
|
|
91
|
+
});
|
package/api/theme/add/add.mjs
CHANGED
|
@@ -26,6 +26,20 @@ function defaultTargetDir(slug) {
|
|
|
26
26
|
return path.join('src', 'themes', slug);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The bytes to write for one listed file. Only UTF-8 text loses our header;
|
|
31
|
+
* anything else (a font, an image) is copied verbatim, since decoding it would
|
|
32
|
+
* corrupt it.
|
|
33
|
+
* @param {Buffer} bytes
|
|
34
|
+
* @returns {Buffer | string}
|
|
35
|
+
*/
|
|
36
|
+
function scaffoldContents(bytes) {
|
|
37
|
+
const text = bytes.toString('utf-8');
|
|
38
|
+
return Buffer.from(text, 'utf-8').equals(bytes)
|
|
39
|
+
? stripCopyrightHeader(text)
|
|
40
|
+
: bytes;
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/**
|
|
30
44
|
* Copy a bundled theme's files into the consumer's project (defaults to
|
|
31
45
|
* `src/themes/<slug>/`). Writes are staged to temp files then renamed, rolling
|
|
@@ -134,9 +148,12 @@ export async function themeAdd(slug, options = {}) {
|
|
|
134
148
|
label: `theme destination for ${w.name}`,
|
|
135
149
|
});
|
|
136
150
|
fs.mkdirSync(path.dirname(dest), {recursive: true});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
151
|
+
// The staging file is an output path too: confine it, and never write
|
|
152
|
+
// through an entry that already exists under its name.
|
|
153
|
+
const tmp = assertWithin(`${w.name}.${process.pid}.tmp`, resolvedDir, {
|
|
154
|
+
label: `theme staging file for ${w.name}`,
|
|
155
|
+
});
|
|
156
|
+
fs.writeFileSync(tmp, scaffoldContents(fs.readFileSync(w.src)), {flag: 'wx'});
|
|
140
157
|
staged.push({tmp, dest});
|
|
141
158
|
}
|
|
142
159
|
for (const s of staged) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
4
|
+
import * as fs from 'node:fs';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import {themeAdd} from './add.mjs';
|
|
8
|
+
import {listThemes} from '../_adapter.mjs';
|
|
9
|
+
import {isErrorCode} from '../../../foundation/response/error-codes.mjs';
|
|
10
|
+
|
|
11
|
+
let tmpDir;
|
|
12
|
+
let outsideDir;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-themeadd-staging-'));
|
|
16
|
+
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-themeadd-outside-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
fs.rmSync(tmpDir, {recursive: true, force: true});
|
|
21
|
+
fs.rmSync(outsideDir, {recursive: true, force: true});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Put a symlink where `theme add` stages the first file of `slug`.
|
|
26
|
+
* @param {string} slug
|
|
27
|
+
* @param {string} target
|
|
28
|
+
*/
|
|
29
|
+
function plantStagingLink(slug, target) {
|
|
30
|
+
const theme = listThemes().find(entry => entry.slug === slug);
|
|
31
|
+
if (!theme) throw new Error(`missing bundled theme ${slug}`);
|
|
32
|
+
const first = theme.files[0];
|
|
33
|
+
const dest = path.join(tmpDir, 'src', 'themes', slug, first);
|
|
34
|
+
fs.mkdirSync(path.dirname(dest), {recursive: true});
|
|
35
|
+
fs.symlinkSync(target, `${dest}.${process.pid}.tmp`);
|
|
36
|
+
return dest;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('themeAdd staging writes stay inside the project', () => {
|
|
40
|
+
it('refuses a staging path that links outside the project', async () => {
|
|
41
|
+
const victim = path.join(outsideDir, 'victim.txt');
|
|
42
|
+
fs.writeFileSync(victim, 'outside\n');
|
|
43
|
+
const dest = plantStagingLink('stone', victim);
|
|
44
|
+
|
|
45
|
+
await expect(themeAdd('stone', {cwd: tmpDir})).rejects.toMatchObject({
|
|
46
|
+
code: 'ERR_PATH_TRAVERSAL',
|
|
47
|
+
});
|
|
48
|
+
expect(fs.readFileSync(victim, 'utf-8')).toBe('outside\n');
|
|
49
|
+
expect(fs.existsSync(dest)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('never creates a file through a dangling staging link', async () => {
|
|
53
|
+
const victim = path.join(outsideDir, 'created.txt');
|
|
54
|
+
plantStagingLink('stone', victim);
|
|
55
|
+
|
|
56
|
+
let error;
|
|
57
|
+
try {
|
|
58
|
+
await themeAdd('stone', {cwd: tmpDir});
|
|
59
|
+
} catch (caught) {
|
|
60
|
+
error = caught;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(isErrorCode(error?.code)).toBe(true);
|
|
64
|
+
expect(fs.existsSync(victim)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|