Skip to content

Commit b943735

Browse files
authored
test(desktop): validate release arguments against the Electron Builder schema (#168)
## Related Issue No issue — follow-up hardening for the release failure fixed in #167. ## Problem The `desktop-v0.2.1` build died at Electron Builder's schema validation on `--config.win.publisherName`, an option removed in v26. The suite was green throughout, because `package-win.spec.ts` compared the generated argument array to a hand-written expected array. Both copies carried the same mistake, so the test only detected *change*, never *invalidity* — Google's "change-detector test". The consequence is structural, not incidental: these arguments are only emitted when signing secrets are present, so a tag build is the first time they ever execute. A test that cannot judge them means the release is the first judge. ## What changed `package-win.spec.ts` now merges the generated `--config.*` arguments onto the packaged `build` configuration and validates the result with Ajv against the installed `app-builder-lib/scheme.json`, using the same Ajv options `app-builder-lib` itself validates with (`allErrors`, `verbose`, `coerceTypes`, `strict: false`). - **No new dependency.** Both `scheme.json` and `ajv` resolve through the existing `electron-builder` dependency (pnpm hides them from a direct resolve out of `apps/desktop`, so resolution is anchored at `require.resolve('electron-builder')`). - **Follows the installed version.** It reads whatever schema the pinned Electron Builder ships, so a future major that relocates these options fails here rather than at a tag. - Failure output is trimmed to the actionable errors instead of Ajv's verbose dump. Three cases: the packaged configuration alone, the configuration each signing method produces, and a negative case pinning the exact regression. ## Verification Mutation-tested, both caught: | Mutation | Result | |---|---| | Reintroduce `--config.win.publisherName` | 3 failed — `Electron Builder rejects unknown options: /win.publisherName` | | Drop required `azureSignOptions.certificateProfileName` | 3 failed — `must have required property 'certificateProfileName'` | | Restored | 17 passed | The second case is a class the previous test could not detect at all. - `pnpm exec vitest run tests/package-win.spec.ts` — 17 passed - `pnpm run typecheck` (apps/desktop) — clean, both tsconfigs - `oxlint --type-aware` — 0 warnings, 0 errors - `scripts/check-no-comments.mjs` — OK ## Checklist - [x] I have read the CONTRIBUTING document. - [ ] I have linked a related issue — none; follow-up to #167. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset — no changeset: test-only, nothing users can perceive. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Improved validation of desktop package configurations against the Electron Builder schema. * Added coverage for base configurations, signing modes, invalid options, and removed settings. * Enhanced error reporting for unknown or invalid configuration values. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 78462b0 commit b943735

1 file changed

Lines changed: 71 additions & 44 deletions

File tree

apps/desktop/tests/package-win.spec.ts

Lines changed: 71 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,49 +6,72 @@ import {
66
windowsSigningArgs,
77
} from '../scripts/package-win'
88

9-
interface SchemaNode {
10-
readonly $ref?: string
11-
readonly anyOf?: readonly SchemaNode[]
12-
readonly properties?: Readonly<Record<string, SchemaNode>>
13-
}
9+
type JsonObject = Record<string, unknown>
1410

1511
const requireFromTests = createRequire(import.meta.url)
16-
const schema = requireFromTests(
17-
requireFromTests.resolve('app-builder-lib/scheme.json', {
18-
paths: [requireFromTests.resolve('electron-builder')],
19-
}),
20-
) as { readonly definitions: Readonly<Record<string, SchemaNode>> }
21-
22-
function referencedDefinition(node: SchemaNode): SchemaNode | undefined {
23-
const reference = node.$ref ?? node.anyOf?.find(branch => branch.$ref !== undefined)?.$ref
24-
return reference === undefined ? undefined : schema.definitions[reference.replace('#/definitions/', '')]
12+
const electronBuilderPath = requireFromTests.resolve('electron-builder')
13+
const schema: unknown = requireFromTests(
14+
requireFromTests.resolve('app-builder-lib/scheme.json', { paths: [electronBuilderPath] }),
15+
)
16+
const Ajv = requireFromTests(requireFromTests.resolve('ajv', { paths: [electronBuilderPath] })) as {
17+
readonly default: new (options: JsonObject) => {
18+
compile: (schema: unknown) => ((data: unknown) => boolean) & { errors?: readonly { instancePath: string, keyword: string, message?: string, params: JsonObject }[] }
19+
}
2520
}
21+
// The same Ajv settings app-builder-lib validates a release configuration with,
22+
// so a configuration this accepts is one Electron Builder accepts.
23+
const validateConfiguration = new Ajv.default({
24+
allErrors: true,
25+
verbose: true,
26+
coerceTypes: true,
27+
strict: false,
28+
}).compile(schema)
29+
30+
const baseConfiguration = (requireFromTests('../package.json') as { readonly build: JsonObject }).build
2631

2732
/**
28-
* Assert that a `--config.<path>` option exists in the installed Electron Builder schema.
29-
*
30-
* `WindowsConfiguration` sets `additionalProperties: false`, so an option that
31-
* the schema does not declare fails validation before any build work and takes
32-
* the whole `win` object down with it. Comparing against the real schema keeps
33-
* these arguments honest across Electron Builder upgrades, which is what an
34-
* expected-array assertion cannot do.
35-
* @param path - Dotted option path with the `--config.` prefix removed.
33+
* Apply generated `--config.<path> <value>` arguments to the packaged build configuration.
34+
* @param args - Arguments as they reach Electron Builder.
35+
* @returns The configuration Electron Builder would validate.
3636
*/
37-
function assertSchemaOption(path: string): void {
38-
const [root, ...rest] = path.split('.')
39-
expect(root).toBe('win')
40-
let definition = schema.definitions['WindowsConfiguration']!
41-
rest.forEach((segment, index) => {
42-
const property = definition.properties?.[segment]
43-
if (property === undefined) {
44-
throw new Error(`Electron Builder has no option '${rest.slice(0, index + 1).join('.')}' under win`)
37+
function configurationFrom(args: readonly string[]): JsonObject {
38+
const configuration = structuredClone(baseConfiguration)
39+
for (let index = 0; index < args.length; index += 1) {
40+
const argument = args[index]!
41+
if (!argument.startsWith('--config.')) continue
42+
const path = argument.slice('--config.'.length).split('.')
43+
let node = configuration
44+
for (const key of path.slice(0, -1)) {
45+
node[key] ??= {}
46+
node = node[key] as JsonObject
4547
}
46-
const next = referencedDefinition(property)
47-
if (next !== undefined) definition = next
48-
else if (index !== rest.length - 1) {
49-
throw new Error(`Electron Builder option 'win.${rest.slice(0, index + 1).join('.')}' has no nested options`)
50-
}
51-
})
48+
node[path.at(-1)!] = args[index + 1]
49+
index += 1
50+
}
51+
return configuration
52+
}
53+
54+
/**
55+
* Assert Electron Builder would accept the configuration these arguments produce.
56+
*
57+
* Comparing generated arguments against a hand-written array cannot tell that
58+
* the arguments are invalid — both copies carry the same mistake. Electron
59+
* Builder validates the merged configuration before it packages anything, so
60+
* running that same validation here fails in the suite instead of at the tag.
61+
* @param args - Arguments as they reach Electron Builder.
62+
*/
63+
function assertConfigurationAccepted(args: readonly string[]): void {
64+
if (validateConfiguration(configurationFrom(args))) return
65+
const errors = validateConfiguration.errors ?? []
66+
const unknown = errors
67+
.filter(error => error.keyword === 'additionalProperties')
68+
.map(error => `${error.instancePath}.${String(error.params['additionalProperty'])}`)
69+
if (unknown.length > 0) throw new Error(`Electron Builder rejects unknown options: ${unknown.join(', ')}`)
70+
// anyOf/type noise follows every real error; the specific keywords name the cause.
71+
const specific = errors.filter(error => error.keyword !== 'anyOf' && error.keyword !== 'type')
72+
const reported = (specific.length > 0 ? specific : errors)
73+
.map(error => `${error.instancePath === '' ? 'configuration' : error.instancePath} ${error.message ?? 'is invalid'}`)
74+
throw new Error(`Electron Builder rejects the configuration: ${[...new Set(reported)].join('; ')}`)
5275
}
5376

5477
const signingEnvironment: NodeJS.ProcessEnv = {
@@ -101,24 +124,28 @@ describe('Windows Azure signing configuration', () => {
101124
})
102125
})
103126

104-
describe('Electron Builder option names', () => {
127+
describe('Electron Builder configuration', () => {
105128
const certificateEnvironment: NodeJS.ProcessEnv = {
106129
WIN_CSC_LINK: 'certificate.p12',
107130
WIN_CSC_KEY_PASSWORD: 'password',
108131
WINDOWS_SIGNING_PUBLISHER_NAME: 'CN=Example Publisher, O=Example Publisher',
109132
}
110133

111-
it('emits only options the installed Electron Builder schema declares', () => {
134+
it('accepts the packaged configuration on its own', () => {
135+
expect(() => { assertConfigurationAccepted([]) }).not.toThrow()
136+
})
137+
138+
it('accepts the configuration every signing method produces', () => {
112139
for (const environment of [signingEnvironment, certificateEnvironment]) {
113-
const options = windowsSigningArgs(environment).filter(argument => argument.startsWith('--config.'))
114-
expect(options.length).toBeGreaterThan(0)
115-
for (const option of options) assertSchemaOption(option.slice('--config.'.length))
140+
const args = windowsSigningArgs(environment)
141+
expect(args.length).toBeGreaterThan(0)
142+
expect(() => { assertConfigurationAccepted(args) }).not.toThrow()
116143
}
117144
})
118145

119-
it('rejects an option the schema does not declare', () => {
120-
expect(() => { assertSchemaOption('win.publisherName') })
121-
.toThrow("Electron Builder has no option 'publisherName' under win")
146+
it('rejects an option Electron Builder has removed', () => {
147+
expect(() => { assertConfigurationAccepted(['--config.win.publisherName', 'CN=Example Publisher']) })
148+
.toThrow('Electron Builder rejects unknown options: /win.publisherName')
122149
})
123150
})
124151

0 commit comments

Comments
 (0)