Skip to content

Commit 754fa94

Browse files
committed
fix(config): read default exports wrapped in assertions or parentheses
getDefaultExportValue reached for a fixed child index, which only lands on the object literal when the export is written `{...} as NativeScriptConfig`. A bare `export default {...}` is the object already - index 0 is its opening brace - so the most natural way to write a config threw "default export must be an object!". Parenthesized and angle-bracket forms failed the same way, in both the TypeScript and CommonJS branches. Unwrap recursively instead, so any nesting of assertions and parentheses resolves to the object and anything that is not one still throws. Predates the ts-morph 28 upgrade: 25.0.1 behaves identically.
1 parent dfae38d commit 754fa94

2 files changed

Lines changed: 171 additions & 12 deletions

File tree

lib/tools/config-manipulation/config-transformer.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,7 @@ import {
1818
} from "ts-morph";
1919

2020
export type SupportedConfigValues =
21-
| string
22-
| number
23-
| boolean
24-
| { [key: string]: SupportedConfigValues }
25-
| any[];
21+
string | number | boolean | { [key: string]: SupportedConfigValues } | any[];
2622

2723
export interface IConfigTransformer {
2824
/**
@@ -68,7 +64,7 @@ export class ConfigTransformer implements IConfigTransformer {
6864
).getExpressionIfKind(SyntaxKind.BinaryExpression);
6965
const leftSide = expression.getLeft() as PropertyAccessExpression;
7066
if (leftSide.getFullText().trim() === "module.exports") {
71-
exportValue = expression.getRight();
67+
exportValue = this.unwrapObjectLiteral(expression.getRight());
7268
return true;
7369
}
7470
}
@@ -80,20 +76,39 @@ export class ConfigTransformer implements IConfigTransformer {
8076
const exports = this.config
8177
.getDefaultExportSymbolOrThrow()
8278
.getDeclarations()[0] as ExportAssignment;
83-
const expr = exports.getExpression();
84-
exportValue =
85-
expr.getChildCount() > 0
86-
? (expr.getChildAtIndex(0) as ObjectLiteralExpression)
87-
: expr;
79+
exportValue = this.unwrapObjectLiteral(exports.getExpression());
8880
}
8981

90-
if (!Node.isObjectLiteralExpression(exportValue)) {
82+
if (!exportValue) {
9183
throw new Error("default export must be an object!");
9284
}
9385

9486
return exportValue;
9587
}
9688

89+
/**
90+
* Strips the type assertions and parentheses a config may wrap its object in
91+
* - `{...} as NativeScriptConfig`, `satisfies`, `<any>{...}`, `({...})` and
92+
* any nesting of those - none of which change the exported object.
93+
* @returns the object literal, or undefined if the export is not one.
94+
*/
95+
private unwrapObjectLiteral(node: Node): ObjectLiteralExpression {
96+
if (Node.isObjectLiteralExpression(node)) {
97+
return node;
98+
}
99+
100+
if (
101+
Node.isParenthesizedExpression(node) ||
102+
Node.isAsExpression(node) ||
103+
Node.isSatisfiesExpression(node) ||
104+
Node.isTypeAssertion(node)
105+
) {
106+
return this.unwrapObjectLiteral(node.getExpression());
107+
}
108+
109+
return undefined;
110+
}
111+
97112
private getProperty(
98113
key: string,
99114
parent: ObjectLiteralExpression = null,

test/tools/config-manipulation/config-transformer.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,148 @@ export default {
5959
spmPackages,
6060
);
6161
});
62+
63+
const tsConfig = `export default {
64+
id: 'org.nativescript.myapp',
65+
appPath: 'src',
66+
version: 3,
67+
} as any;`;
68+
69+
const roundTrip = (content: string, path: string, value: any) =>
70+
new ConfigTransformer(
71+
new ConfigTransformer(content).setValue(path, value),
72+
).getValue(path);
73+
74+
it("reads and updates string literals", () => {
75+
assert.strictEqual(
76+
new ConfigTransformer(tsConfig).getValue("id"),
77+
"org.nativescript.myapp",
78+
);
79+
assert.strictEqual(roundTrip(tsConfig, "appPath", "app"), "app");
80+
});
81+
82+
it("reads and updates numeric literals", () => {
83+
assert.strictEqual(new ConfigTransformer(tsConfig).getValue("version"), 3);
84+
assert.strictEqual(roundTrip(tsConfig, "version", 4), 4);
85+
});
86+
87+
it("replaces the initializer when the new value changes type", () => {
88+
assert.strictEqual(roundTrip(tsConfig, "version", "four"), "four");
89+
assert.strictEqual(roundTrip(tsConfig, "appPath", 7), 7);
90+
});
91+
92+
it("reads and updates CommonJS configs", () => {
93+
const content = `module.exports = {
94+
id: 'org.nativescript.myapp',
95+
appPath: 'src',
96+
};`;
97+
98+
assert.strictEqual(
99+
new ConfigTransformer(content).getValue("id"),
100+
"org.nativescript.myapp",
101+
);
102+
assert.strictEqual(roundTrip(content, "appPath", "app"), "app");
103+
});
104+
105+
// the object may be wrapped in any combination of assertions and parentheses
106+
const wrappers: [string, string][] = [
107+
["no assertion", `{ id: 'org.nativescript.myapp' }`],
108+
[
109+
"as NativeScriptConfig",
110+
`{ id: 'org.nativescript.myapp' } as NativeScriptConfig`,
111+
],
112+
["as any", `{ id: 'org.nativescript.myapp' } as any`],
113+
["as const", `{ id: 'org.nativescript.myapp' } as const`],
114+
[
115+
"satisfies",
116+
`{ id: 'org.nativescript.myapp' } satisfies NativeScriptConfig`,
117+
],
118+
["angle-bracket assertion", `<any>{ id: 'org.nativescript.myapp' }`],
119+
["parenthesized", `({ id: 'org.nativescript.myapp' })`],
120+
[
121+
"nested parens and assertion",
122+
`(({ id: 'org.nativescript.myapp' } as any))`,
123+
],
124+
];
125+
126+
for (const [label, expression] of wrappers) {
127+
it(`reads and updates a default export wrapped in ${label}`, () => {
128+
const content = `export default ${expression};`;
129+
130+
assert.strictEqual(
131+
new ConfigTransformer(content).getValue("id"),
132+
"org.nativescript.myapp",
133+
);
134+
assert.strictEqual(roundTrip(content, "id", "org.other"), "org.other");
135+
});
136+
}
137+
138+
it("reads and updates a parenthesized CommonJS export", () => {
139+
const content = `module.exports = ({
140+
id: 'org.nativescript.myapp',
141+
});`;
142+
143+
assert.strictEqual(
144+
new ConfigTransformer(content).getValue("id"),
145+
"org.nativescript.myapp",
146+
);
147+
assert.strictEqual(roundTrip(content, "id", "org.other"), "org.other");
148+
});
149+
150+
it("creates intermediate objects for a new dot-notation path", () => {
151+
assert.strictEqual(
152+
roundTrip(tsConfig, "android.markingMode", "none"),
153+
"none",
154+
);
155+
});
156+
157+
it("adds keys that are absent from the config", () => {
158+
assert.strictEqual(
159+
roundTrip(tsConfig, "appResourcesPath", "App_Resources"),
160+
"App_Resources",
161+
);
162+
assert.deepStrictEqual(
163+
roundTrip(tsConfig, "ios", { discardUncaughtJsExceptions: true }),
164+
{
165+
discardUncaughtJsExceptions: true,
166+
},
167+
);
168+
});
169+
170+
it("resolves a value declared as a separate variable", () => {
171+
const content = `const appId = 'org.nativescript.myapp';
172+
173+
export default {
174+
id: appId,
175+
} as any;`;
176+
177+
assert.strictEqual(
178+
new ConfigTransformer(content).getValue("id"),
179+
"org.nativescript.myapp",
180+
);
181+
// the assignment is indirect, so the update lands on the declaration
182+
const updated = new ConfigTransformer(content).setValue("id", "org.other");
183+
assert.include(updated, "const appId = 'org.other'");
184+
assert.strictEqual(
185+
new ConfigTransformer(updated).getValue("id"),
186+
"org.other",
187+
);
188+
});
189+
190+
it("returns undefined for a key that is not present", () => {
191+
assert.isUndefined(
192+
new ConfigTransformer(tsConfig).getValue("doesNotExist"),
193+
);
194+
});
195+
196+
it("throws when the default export is not an object", () => {
197+
assert.throws(
198+
() => new ConfigTransformer(`export default 42;`).getValue("id"),
199+
"default export must be an object!",
200+
);
201+
assert.throws(
202+
() => new ConfigTransformer(`module.exports = 42;`).getValue("id"),
203+
"default export must be an object!",
204+
);
205+
});
62206
});

0 commit comments

Comments
 (0)