@itmetsam/samurai-framework 0.19.0 → 0.21.0

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.
Files changed (134) hide show
  1. package/dist/api.d.ts +1 -1
  2. package/dist/assert/assertion-error.d.ts +3 -1
  3. package/dist/assert/assertion-error.js +2 -2
  4. package/dist/assert/locator-assertions.d.ts +35 -4
  5. package/dist/assert/locator-assertions.js +56 -28
  6. package/dist/assert/page-assertions.js +1 -1
  7. package/dist/assert/pending-assertions.d.ts +3 -2
  8. package/dist/assert/pending-assertions.js +5 -4
  9. package/dist/assert/text-match.d.ts +6 -0
  10. package/dist/assert/text-match.js +12 -0
  11. package/dist/capabilities.d.ts +16 -3
  12. package/dist/capabilities.js +16 -3
  13. package/dist/cli/flow-reporter.d.ts +1 -1
  14. package/dist/cli/flow-reporter.js +2 -2
  15. package/dist/cli/flow.js +4 -4
  16. package/dist/flows/core/check-io.d.ts +23 -0
  17. package/dist/flows/core/check-io.js +84 -0
  18. package/dist/flows/core/check.d.ts +16 -2
  19. package/dist/flows/core/check.js +20 -4
  20. package/dist/flows/core/expression/scope.d.ts +9 -2
  21. package/dist/flows/core/expression/scope.js +12 -4
  22. package/dist/flows/core/index.d.ts +2 -1
  23. package/dist/flows/core/index.js +2 -1
  24. package/dist/flows/core/nodes/condition.js +0 -1
  25. package/dist/flows/core/nodes/define.d.ts +0 -2
  26. package/dist/flows/core/nodes/group.js +0 -1
  27. package/dist/flows/core/nodes/test.js +0 -1
  28. package/dist/flows/core/schema.d.ts +10 -5
  29. package/dist/flows/core/schema.js +29 -1
  30. package/dist/flows/core/steps.d.ts +7 -0
  31. package/dist/flows/core/steps.js +15 -0
  32. package/dist/flows/core/wiring.d.ts +72 -0
  33. package/dist/flows/core/wiring.js +151 -0
  34. package/dist/flows/run/branch-context.d.ts +10 -4
  35. package/dist/flows/run/branch-context.js +6 -4
  36. package/dist/flows/run/events.d.ts +30 -2
  37. package/dist/flows/run/handlers/group.js +6 -2
  38. package/dist/flows/run/handlers/support.d.ts +7 -2
  39. package/dist/flows/run/handlers/support.js +36 -7
  40. package/dist/flows/run/handlers/test.js +6 -2
  41. package/dist/flows/run/handlers/types.d.ts +11 -3
  42. package/dist/flows/run/index.d.ts +1 -0
  43. package/dist/flows/run/index.js +1 -0
  44. package/dist/flows/run/node-folder.d.ts +10 -0
  45. package/dist/flows/run/node-folder.js +32 -0
  46. package/dist/flows/run/node-inputs.d.ts +5 -0
  47. package/dist/flows/run/node-inputs.js +62 -0
  48. package/dist/flows/run/node-pool.d.ts +2 -2
  49. package/dist/flows/run/node-pool.js +6 -6
  50. package/dist/flows/run/node-runner.d.ts +24 -0
  51. package/dist/flows/run/node-runner.js +48 -0
  52. package/dist/flows/run/per-browser.d.ts +21 -0
  53. package/dist/flows/run/per-browser.js +46 -0
  54. package/dist/flows/run/project-facts.d.ts +13 -0
  55. package/dist/flows/run/project-facts.js +12 -0
  56. package/dist/flows/run/project-options.d.ts +9 -2
  57. package/dist/flows/run/project.d.ts +13 -8
  58. package/dist/flows/run/project.js +48 -51
  59. package/dist/flows/run/report.d.ts +7 -7
  60. package/dist/flows/run/report.js +3 -5
  61. package/dist/flows/run/run.d.ts +21 -7
  62. package/dist/flows/run/run.js +21 -11
  63. package/dist/flows/run/walker.d.ts +23 -7
  64. package/dist/flows/run/walker.js +18 -7
  65. package/dist/locator/locator.d.ts +3 -1
  66. package/dist/locator/locator.js +6 -0
  67. package/dist/recorder/apply.js +17 -7
  68. package/dist/recorder/normalise.d.ts +18 -3
  69. package/dist/recorder/normalise.js +18 -3
  70. package/dist/recorder/session-secrets.js +4 -0
  71. package/dist/recorder/toolbar/action-guard.d.ts +2 -5
  72. package/dist/recorder/toolbar/action-guard.js +9 -5
  73. package/dist/recorder/toolbar/actions/insert.d.ts +5 -2
  74. package/dist/recorder/toolbar/actions/insert.js +43 -7
  75. package/dist/recorder/toolbar/actions/pick.js +2 -0
  76. package/dist/recorder/toolbar/actions/reply.d.ts +2 -0
  77. package/dist/recorder/toolbar/actions/reply.js +9 -0
  78. package/dist/recorder/toolbar/actions/save-output.d.ts +9 -0
  79. package/dist/recorder/toolbar/actions/save-output.js +46 -0
  80. package/dist/recorder/toolbar/controller-fixture.d.ts +2 -2
  81. package/dist/recorder/toolbar/controller-fixture.js +2 -0
  82. package/dist/recorder/toolbar/controller.d.ts +2 -1
  83. package/dist/recorder/toolbar/controller.js +14 -7
  84. package/dist/recorder/toolbar/insertables.d.ts +7 -0
  85. package/dist/recorder/toolbar/insertables.js +12 -0
  86. package/dist/recorder/toolbar/inspect.js +3 -1
  87. package/dist/recorder/toolbar/protocol.d.ts +20 -3
  88. package/dist/recorder/toolbar/source/events-page.js +2 -1
  89. package/dist/recorder/toolbar/source/fields.js +8 -0
  90. package/dist/recorder/toolbar/source/handlers.js +19 -12
  91. package/dist/recorder/toolbar/source/picking.js +22 -7
  92. package/dist/recorder/toolbar/source/recorder-side.js +6 -0
  93. package/dist/recorder/toolbar/source/setup.js +3 -3
  94. package/dist/recorder/toolbar/source/views-panels.d.ts +1 -1
  95. package/dist/recorder/toolbar/source/views-panels.js +50 -10
  96. package/dist/recorder/toolbar/source/views-rail.js +5 -2
  97. package/dist/recorder/toolbar/source.d.ts +5 -5
  98. package/dist/recorder/toolbar/source.js +5 -5
  99. package/dist/recorder/toolbar/types.d.ts +10 -3
  100. package/dist/runner/options.d.ts +5 -0
  101. package/dist/runner/pooled-run.d.ts +3 -2
  102. package/dist/runner/pooled-run.js +4 -2
  103. package/dist/runner/registry.d.ts +7 -3
  104. package/dist/runner/registry.js +29 -11
  105. package/dist/runner/reporter.d.ts +4 -0
  106. package/dist/runner/reporter.js +2 -0
  107. package/dist/runner/run-test.js +23 -2
  108. package/dist/runner/run.d.ts +7 -0
  109. package/dist/runner/run.js +8 -1
  110. package/dist/runner/test-io.d.ts +30 -0
  111. package/dist/runner/test-io.js +111 -0
  112. package/dist/runner/test-runner.js +1 -1
  113. package/dist/runner/worker-protocol.d.ts +2 -0
  114. package/dist/runner/worker-session.js +1 -0
  115. package/dist/runner/worker.d.ts +1 -0
  116. package/dist/runner/worker.js +4 -1
  117. package/dist/steps/edit.d.ts +14 -3
  118. package/dist/steps/edit.js +65 -23
  119. package/dist/steps/emit.d.ts +2 -0
  120. package/dist/steps/emit.js +32 -8
  121. package/dist/steps/index.d.ts +1 -1
  122. package/dist/steps/model.d.ts +42 -2
  123. package/dist/steps/parse/expectation.d.ts +8 -1
  124. package/dist/steps/parse/expectation.js +17 -11
  125. package/dist/steps/parse/step.js +64 -8
  126. package/dist/steps/parse.js +25 -16
  127. package/dist/steps/test-options.d.ts +25 -0
  128. package/dist/steps/test-options.js +158 -0
  129. package/dist/steps/text.d.ts +7 -0
  130. package/dist/steps/text.js +12 -0
  131. package/dist/types/test.d.ts +34 -0
  132. package/dist/wait/output-sink.d.ts +24 -0
  133. package/dist/wait/output-sink.js +59 -0
  134. package/package.json +1 -1
@@ -0,0 +1,158 @@
1
+ import ts from "typescript";
2
+ import { isIoName } from "../flows/core/steps.js";
3
+ import { IDENTIFIER } from "./emit.js";
4
+ import { stringOf } from "./parse/literals.js";
5
+ import { indentOf, splice } from "./text.js";
6
+ function keyOf(name) {
7
+ return ts.isIdentifier(name) || ts.isStringLiteral(name)
8
+ ? name.text
9
+ : undefined;
10
+ }
11
+ /** `{ name: "default", … }`; undefined for anything but string literals under plain keys */
12
+ function readInputs(node) {
13
+ if (!ts.isObjectLiteralExpression(node))
14
+ return undefined;
15
+ const inputs = [];
16
+ for (const property of node.properties) {
17
+ if (!ts.isPropertyAssignment(property))
18
+ return undefined;
19
+ const name = keyOf(property.name);
20
+ const value = stringOf(property.initializer);
21
+ if (name === undefined || value === undefined)
22
+ return undefined;
23
+ inputs.push({ name, default: value });
24
+ }
25
+ return inputs;
26
+ }
27
+ /** `["name", …]`; undefined for anything but string literals */
28
+ function readOutputs(node) {
29
+ if (!ts.isArrayLiteralExpression(node))
30
+ return undefined;
31
+ const names = node.elements.map(stringOf);
32
+ return names.every((name) => name !== undefined)
33
+ ? names
34
+ : undefined;
35
+ }
36
+ /**
37
+ * The options object of a test or describe call: a describe takes only `reason`, a test also `inputs` and
38
+ * `outputs`. Undefined when the object isn't a literal the codec can read, so the call is left unparsed. An
39
+ * empty list is left out, as the runtime leaves it out. A `reason` counts only for skip and fixme, and one that
40
+ * isn't a string literal is passed over, as before
41
+ */
42
+ export function readTestOptions(node, kind, modifier) {
43
+ if (!ts.isObjectLiteralExpression(node))
44
+ return undefined;
45
+ const read = {};
46
+ for (const property of node.properties) {
47
+ if (!ts.isPropertyAssignment(property))
48
+ return undefined;
49
+ const key = keyOf(property.name);
50
+ const value = property.initializer;
51
+ if (key === "reason") {
52
+ const reason = stringOf(value);
53
+ if (reason !== undefined && (modifier === "skip" || modifier === "fixme"))
54
+ read.reason = reason;
55
+ }
56
+ else if (kind === "test" && key === "inputs") {
57
+ const inputs = readInputs(value);
58
+ if (!inputs)
59
+ return undefined;
60
+ if (inputs.length > 0)
61
+ read.inputs = inputs;
62
+ }
63
+ else if (kind === "test" && key === "outputs") {
64
+ const outputs = readOutputs(value);
65
+ if (!outputs)
66
+ return undefined;
67
+ if (outputs.length > 0)
68
+ read.outputs = outputs;
69
+ }
70
+ else
71
+ return undefined;
72
+ }
73
+ return read;
74
+ }
75
+ const str = (value) => JSON.stringify(value);
76
+ function listToSource(key, list) {
77
+ if (key === "outputs")
78
+ return `[${list.map(str).join(", ")}]`;
79
+ const entries = list.map(({ name, default: value }) => `${IDENTIFIER.test(name) ? name : str(name)}: ${str(value)}`);
80
+ return `{ ${entries.join(", ")} }`;
81
+ }
82
+ const sameList = (a, b) => JSON.stringify(a) === JSON.stringify(b);
83
+ /** Throws for a list the runtime's `test()` would reject: a name that isn't valid, a name twice, a default that isn't text */
84
+ function checkList(key, list) {
85
+ const kind = key === "inputs" ? "input" : "output";
86
+ const seen = new Set();
87
+ for (const entry of list) {
88
+ const name = typeof entry === "string" ? entry : entry.name;
89
+ if (!isIoName(name))
90
+ throw new Error(`applyEdit(): ${kind} ${JSON.stringify(name)} is not a valid name`);
91
+ if (seen.has(name))
92
+ throw new Error(`applyEdit(): ${kind} "${name}" is listed twice`);
93
+ seen.add(name);
94
+ if (typeof entry !== "string" && typeof entry.default !== "string")
95
+ throw new Error(`applyEdit(): input "${name}" needs a string default`);
96
+ }
97
+ }
98
+ /** The options object at `range`, parsed on its own, with offsets mapped back into the source */
99
+ function optionsObject(source, range) {
100
+ const file = ts.createSourceFile("options.ts", `(${source.slice(range.start, range.end)})`, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
101
+ const statement = file.statements[0];
102
+ const object = statement.expression
103
+ .expression;
104
+ const at = (offset) => offset - 1 + range.start;
105
+ return object.properties.map((property) => {
106
+ const assignment = property;
107
+ return {
108
+ key: keyOf(assignment.name),
109
+ start: at(assignment.getStart(file)),
110
+ end: at(assignment.end),
111
+ valueStart: at(assignment.initializer.getStart(file)),
112
+ emptyList: (ts.isObjectLiteralExpression(assignment.initializer) &&
113
+ assignment.initializer.properties.length === 0) ||
114
+ (ts.isArrayLiteralExpression(assignment.initializer) &&
115
+ assignment.initializer.elements.length === 0),
116
+ };
117
+ });
118
+ }
119
+ /**
120
+ * Sets one list of the test's options object: replaces it, adds it (making the object when there is none) or,
121
+ * for an empty list, removes it, and the whole object when nothing but empty lists would be left. The rest of
122
+ * the object, its layout and a `reason`, stays as it is; an unchanged list leaves the source as it is. Throws for
123
+ * names the runtime rejects, before writing
124
+ */
125
+ export function declareList(source, parsed, key, list) {
126
+ checkList(key, list);
127
+ if (list.length > 0 && sameList(parsed[key], list))
128
+ return source;
129
+ const text = `${key}: ${listToSource(key, list)}`;
130
+ if (!parsed.options)
131
+ return list.length === 0
132
+ ? source
133
+ : splice(source, parsed.titleEnd, parsed.titleEnd, `, { ${text} }`);
134
+ const properties = optionsObject(source, parsed.options);
135
+ const index = properties.findIndex((property) => property.key === key);
136
+ const property = properties[index];
137
+ if (list.length === 0) {
138
+ // An empty list that is there still goes (the parse leaves it out), so an object of only empty lists is removed
139
+ if (!property)
140
+ return source;
141
+ const others = properties.filter((_, i) => i !== index);
142
+ if (others.every((other) => other.emptyList))
143
+ return splice(source, parsed.titleEnd, parsed.options.end, "");
144
+ return index > 0
145
+ ? splice(source, properties[index - 1].end, property.end, "")
146
+ : splice(source, property.start, properties[1].start, "");
147
+ }
148
+ if (property)
149
+ return splice(source, property.valueStart, property.end, listToSource(key, list));
150
+ const last = properties[properties.length - 1];
151
+ if (!last)
152
+ return splice(source, parsed.options.start, parsed.options.end, `{ ${text} }`);
153
+ const multiline = source
154
+ .slice(parsed.options.start, properties[0].start)
155
+ .includes("\n");
156
+ const separator = multiline ? `\n${indentOf(source, last.start)}` : " ";
157
+ return splice(source, last.end, last.end, `,${separator}${text}`);
158
+ }
@@ -0,0 +1,7 @@
1
+ /** Offset of the first character of the line `offset` is on */
2
+ export declare const lineStart: (source: string, offset: number) => number;
3
+ /** Offset just after the newline that ends the line `offset` is on, or the end of the source */
4
+ export declare function lineEnd(source: string, offset: number): number;
5
+ /** The spaces and tabs that start the line `offset` is on */
6
+ export declare const indentOf: (source: string, offset: number) => string;
7
+ export declare function splice(source: string, from: number, to: number, text: string): string;
@@ -0,0 +1,12 @@
1
+ /** Offset of the first character of the line `offset` is on */
2
+ export const lineStart = (source, offset) => source.lastIndexOf("\n", offset - 1) + 1;
3
+ /** Offset just after the newline that ends the line `offset` is on, or the end of the source */
4
+ export function lineEnd(source, offset) {
5
+ const newline = source.indexOf("\n", offset);
6
+ return newline === -1 ? source.length : newline + 1;
7
+ }
8
+ /** The spaces and tabs that start the line `offset` is on */
9
+ export const indentOf = (source, offset) => /^[ \t]*/.exec(source.slice(lineStart(source, offset)))[0];
10
+ export function splice(source, from, to, text) {
11
+ return source.slice(0, from) + text + source.slice(to);
12
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Browser } from "../browser/browser.js";
2
2
  import type Page from "../browser/page.js";
3
+ import type Locator from "../locator/locator.js";
3
4
  import type { SupportedBrowser } from "./browser.js";
4
5
 
5
6
  /** What a test function receives */
@@ -14,6 +15,19 @@ export interface TestFixtures {
14
15
  env: Readonly<Record<string, string | number | boolean>>;
15
16
  /** Secrets by name (SAMURAI_SECRET_<NAME>); reading an unset name throws */
16
17
  secrets: Readonly<Record<string, string>>;
18
+ /** The inputs the test declared: the value the run supplies, else the default; reading another name throws */
19
+ inputs: Readonly<Record<string, string>>;
20
+ /** Saves the text values the test declared as outputs */
21
+ outputs: TestOutputs;
22
+ }
23
+
24
+ /** The `outputs` fixture */
25
+ export interface TestOutputs {
26
+ /**
27
+ * Waits up to the expect timeout for the element to have text and saves its trimmed text as output `name`.
28
+ * With `pattern`, waits until the text matches and saves the first capture group, else the whole match.
29
+ */
30
+ save(name: string, locator: Locator, pattern?: RegExp): Promise<void>;
17
31
  }
18
32
 
19
33
  export type TestCase = TestCaseBase;
@@ -27,6 +41,14 @@ export interface TestDetails {
27
41
  reason?: string;
28
42
  }
29
43
 
44
+ /** The optional second argument of `test`, `test.only`, `test.skip` and `test.fixme` */
45
+ export interface TestOptions extends TestDetails {
46
+ /** Named text values the test reads, each with the default it gets when nothing supplies one */
47
+ inputs?: Record<string, string>;
48
+ /** Names of the text values the test saves for later tests to use */
49
+ outputs?: string[];
50
+ }
51
+
30
52
  /** Runs before or after each test of a suite, in the test's browser, with the test's fixtures */
31
53
  export type EachHook = (fixtures: TestFixtures) => Promise<void> | void;
32
54
 
@@ -64,6 +86,10 @@ export interface RegisteredTestCase extends TestCaseBase {
64
86
  modifier?: Modifier;
65
87
  /** The reason given with the `skip` or `fixme` that applies */
66
88
  reason?: string;
89
+ /** The inputs the test declared, with their defaults */
90
+ inputs?: Record<string, string>;
91
+ /** The names of the outputs the test declared */
92
+ outputs?: string[];
67
93
  }
68
94
 
69
95
  export interface TestCaseBase {
@@ -88,6 +114,10 @@ interface TestResultSuccess extends TestResultIdentity, TestRetries {
88
114
  logsDropped?: number;
89
115
  /** Named screenshots the test saved, relative to the result folder */
90
116
  screenshots?: { name: string; path: string }[];
117
+ /** The inputs the test declared, with the values this attempt read; absent when it declares none */
118
+ inputs?: Record<string, string>;
119
+ /** The outputs the test saved; absent when it declares none */
120
+ outputs?: Record<string, string>;
91
121
  }
92
122
 
93
123
  /** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
@@ -98,6 +128,10 @@ interface TestResultFailed extends TestResultIdentity, TestError, TestRetries {
98
128
  logsDropped?: number;
99
129
  /** Named screenshots the test saved, relative to the result folder */
100
130
  screenshots?: { name: string; path: string }[];
131
+ /** The inputs the test declared, with the values this attempt read; absent when it declares none */
132
+ inputs?: Record<string, string>;
133
+ /** The outputs the test saved; absent when it declares none */
134
+ outputs?: Record<string, string>;
101
135
  }
102
136
 
103
137
  /** What a result says about the retries of its test; absent when it passed or failed on the first attempt */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Where one attempt of a test keeps the outputs it saves. Once closed it ignores what still arrives, so a
3
+ * capture that outlives its test (it timed out, or nobody awaited it) never lands in the next one.
4
+ */
5
+ export interface OutputSink {
6
+ /** Whether the test declared `name` */
7
+ declares(name: string): boolean;
8
+ /** Keeps `value` under `name`; throws for a name the test did not declare, ignores it once closed */
9
+ set(name: string, value: string): void;
10
+ /** What was saved so far */
11
+ values(): Record<string, string>;
12
+ }
13
+ /** Opens the sink of an attempt that declared `declared`, closing any one still open */
14
+ export declare function beginOutputs(declared: readonly string[]): OutputSink;
15
+ /** The open sink, or none outside a test. A capture takes it when it starts and saves into that one */
16
+ export declare function currentOutputSink(): OutputSink | undefined;
17
+ /** Closes the open sink and returns what it holds */
18
+ export declare function endOutputs(): Record<string, string>;
19
+ /**
20
+ * Takes the open sink for a capture that starts now (`method` names the call in the errors) and returns the
21
+ * function that saves `value` under `name` into it. The sink is bound here: a value that arrives after the test
22
+ * ended goes to its own closed sink and is ignored.
23
+ */
24
+ export declare function bindOutput(method: string, name: string): (value: string) => void;
@@ -0,0 +1,59 @@
1
+ /*
2
+ * The outputs a running test saves. It lives here, below `assert` and `locator`, because both save into it
3
+ * (`fill`, the text checks) and neither may import `runner`.
4
+ */
5
+ class Sink {
6
+ constructor(declared) {
7
+ this.declared = declared;
8
+ this.saved = {};
9
+ this.closed = false;
10
+ }
11
+ declares(name) {
12
+ return this.declared.includes(name);
13
+ }
14
+ set(name, value) {
15
+ if (this.closed)
16
+ return;
17
+ if (!this.declares(name))
18
+ throw new Error(`output ${JSON.stringify(name)} is not declared`);
19
+ this.saved[name] = value;
20
+ }
21
+ values() {
22
+ return { ...this.saved };
23
+ }
24
+ }
25
+ /** The sink of the attempt that is running; one at a time, like the pending assertions */
26
+ let current;
27
+ /** Opens the sink of an attempt that declared `declared`, closing any one still open */
28
+ export function beginOutputs(declared) {
29
+ if (current)
30
+ current.closed = true;
31
+ current = new Sink(declared);
32
+ return current;
33
+ }
34
+ /** The open sink, or none outside a test. A capture takes it when it starts and saves into that one */
35
+ export function currentOutputSink() {
36
+ return current;
37
+ }
38
+ /** Closes the open sink and returns what it holds */
39
+ export function endOutputs() {
40
+ const sink = current;
41
+ current = undefined;
42
+ if (!sink)
43
+ return {};
44
+ sink.closed = true;
45
+ return sink.values();
46
+ }
47
+ /**
48
+ * Takes the open sink for a capture that starts now (`method` names the call in the errors) and returns the
49
+ * function that saves `value` under `name` into it. The sink is bound here: a value that arrives after the test
50
+ * ended goes to its own closed sink and is ignored.
51
+ */
52
+ export function bindOutput(method, name) {
53
+ const sink = current;
54
+ if (!sink)
55
+ throw new Error(`${method}: output can only be saved inside a test`);
56
+ if (!sink.declares(name))
57
+ throw new Error(`${method}: output ${JSON.stringify(name)} is not declared`);
58
+ return (value) => sink.set(name, value);
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itmetsam/samurai-framework",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
5
5
  "main": "dist/api.js",
6
6
  "exports": {