Skip to content

Commit f49ff77

Browse files
test(vite-plugin-angular): close drift and resource-seam coverage gaps
Extends the upstream-drift and integration coverage the conformance suite (which tests compile() directly) can't reach: - decorator-coverage.spec: also detect FIELD_DECORATORS drift (member decorators via makePropDecorator) and assert COMPILABLE_DECORATORS stays a subset of ANGULAR_DECORATORS (Injectable/Service excluded). - signal-api-coverage.spec: detect SIGNAL_APIS drift against ngtsc's canonical InitializerApiFunction list, so a new signal/initializer API can't silently lose metadata extraction. - component.spec: matrix-cover external styleUrl extension reporting (css/scss/less). - resource-parity.spec: external templateUrl/styleUrls must emit identical Ivy to the inline form, guarding the resource-inlining seam generically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9f64cc9 commit f49ff77

5 files changed

Lines changed: 212 additions & 25 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
:host {
2+
display: block;
3+
}
4+
.wrapper {
5+
padding: 1rem;
6+
}

packages/vite-plugin-angular/src/lib/compiler/component.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2424,6 +2424,28 @@ describe('OXC-based resource inlining', () => {
24242424
expect(styleExtensions.has(0)).toBe(false);
24252425
expect(styleExtensions.get(1)).toBe('scss');
24262426
});
2427+
2428+
it('reports the source extension for each preprocessable styleUrl type', () => {
2429+
// The fast-compile path preprocesses each external style by its own
2430+
// extension, so every preprocessable style language must be surfaced
2431+
// distinctly (not collapsed onto a single `inlineStylesExtension`).
2432+
for (const ext of ['css', 'scss', 'less']) {
2433+
const { styleExtensions } = inlineResourceUrls(
2434+
`
2435+
import { Component } from '@angular/core';
2436+
@Component({
2437+
selector: 'app-ext',
2438+
template: '',
2439+
styleUrl: './test.component.${ext}'
2440+
})
2441+
export class ExtComponent {}
2442+
`,
2443+
__dirname + '/__fixtures__/test.component.ts',
2444+
);
2445+
2446+
expect(styleExtensions.get(0), `extension for .${ext}`).toBe(ext);
2447+
}
2448+
});
24272449
});
24282450

24292451
describe.skipIf(!SUPPORTS_DEFER_RUNTIME)(
Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,38 @@
11
import { describe, it, expect } from 'vitest';
22
import * as fs from 'node:fs';
33
import * as path from 'node:path';
4-
import { ANGULAR_DECORATORS } from './constants';
4+
import {
5+
ANGULAR_DECORATORS,
6+
COMPILABLE_DECORATORS,
7+
FIELD_DECORATORS,
8+
} from './constants';
59

610
/**
7-
* Drift detector against upstream `angular/angular`, mirroring the
11+
* Drift detectors against upstream `angular/angular`, mirroring the
812
* compliance-category detector in `conformance.spec.ts` but at the decorator
913
* level.
1014
*
11-
* The fast-compile path decides "is this an Angular file?" from a fixed set of
12-
* class decorators ({@link ANGULAR_DECORATORS}). When Angular ships a new class
13-
* decorator (e.g. `@Service` in v22) the set must grow with it — otherwise a
14-
* file whose only Angular decorator is the new one is treated as non-Angular,
15-
* skipped by the compiler, and silently falls back to JIT at runtime.
15+
* The fast-compile path decides "is this an Angular file?" and which class
16+
* members to extract from fixed sets of decorator names. When Angular ships a
17+
* new decorator (e.g. `@Service` in v22) the relevant set must grow with it —
18+
* otherwise a class/member using only the new decorator is silently skipped.
1619
*
1720
* Angular declares class decorators as
1821
* `export const X: XDecorator = makeDecorator(...)`
19-
* and member decorators (`@Input`, `@Output`, ...) via `makePropDecorator`.
20-
* Only the former trigger Ivy class compilation, so only the former belong in
21-
* `ANGULAR_DECORATORS`.
22+
* and member decorators (`@Input`, `@Output`, `@ViewChild`, ...) as
23+
* `export const X: XDecorator = makePropDecorator(...)`
24+
* so the factory name selects which set we extract.
2225
*/
2326
const ANGULAR_ROOT =
2427
process.env.ANGULAR_SOURCE_DIR ||
2528
path.resolve(process.env.HOME ?? '', 'projects/angular/angular');
2629
const CORE_SRC = path.join(ANGULAR_ROOT, 'packages/core/src');
2730

28-
const CLASS_DECORATOR_RE =
29-
/export const (\w+)\s*(?::[^=\n]*)?=\s*makeDecorator\b/g;
30-
31-
function collectClassDecorators(dir: string): Set<string> {
31+
function collectDeclarations(dir: string, factory: string): Set<string> {
32+
const re = new RegExp(
33+
`export const (\\w+)\\s*(?::[^=\\n]*)?=\\s*${factory}\\b`,
34+
'g',
35+
);
3236
const names = new Set<string>();
3337
const walk = (current: string) => {
3438
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
@@ -41,10 +45,8 @@ function collectClassDecorators(dir: string): Set<string> {
4145
!entry.name.endsWith('.spec.ts')
4246
) {
4347
const code = fs.readFileSync(full, 'utf-8');
44-
if (!code.includes('makeDecorator')) continue;
45-
for (const match of code.matchAll(CLASS_DECORATOR_RE)) {
46-
names.add(match[1]);
47-
}
48+
if (!code.includes(factory)) continue;
49+
for (const match of code.matchAll(re)) names.add(match[1]);
4850
}
4951
}
5052
};
@@ -53,22 +55,20 @@ function collectClassDecorators(dir: string): Set<string> {
5355
}
5456

5557
describe.skipIf(!fs.existsSync(CORE_SRC))(
56-
'Angular class decorator coverage',
58+
'Angular decorator coverage (upstream drift)',
5759
() => {
5860
it('ANGULAR_DECORATORS covers every class decorator @angular/core ships', () => {
59-
const upstream = collectClassDecorators(CORE_SRC);
61+
const upstream = collectDeclarations(CORE_SRC, 'makeDecorator');
6062

61-
// Guard against a vacuous pass: if the upstream layout changed and the
62-
// scan matched nothing, fail loudly instead of silently "covering" an
63-
// empty set. `Component`/`Injectable` exist in every supported version.
63+
// Guard against a vacuous pass: if the scan matched nothing the upstream
64+
// layout changed. `Component`/`Injectable` exist in every version.
6465
expect(
6566
upstream.has('Component') && upstream.has('Injectable'),
66-
`Found no Angular class decorators under ${CORE_SRC}; the upstream ` +
67+
`No class decorators found under ${CORE_SRC}; the upstream ` +
6768
`\`makeDecorator\` layout likely changed — update this detector.`,
6869
).toBe(true);
6970

7071
const missing = [...upstream].filter((d) => !ANGULAR_DECORATORS.has(d));
71-
7272
expect(
7373
missing,
7474
`@angular/core declares class decorator(s) the fast compiler does not ` +
@@ -78,5 +78,37 @@ describe.skipIf(!fs.existsSync(CORE_SRC))(
7878
`decorators would be treated as non-Angular and skipped.`,
7979
).toEqual([]);
8080
});
81+
82+
it('FIELD_DECORATORS covers every member decorator @angular/core ships', () => {
83+
const upstream = collectDeclarations(CORE_SRC, 'makePropDecorator');
84+
85+
expect(
86+
upstream.has('Input') && upstream.has('Output'),
87+
`No member decorators found under ${CORE_SRC}; the upstream ` +
88+
`\`makePropDecorator\` layout likely changed — update this detector.`,
89+
).toBe(true);
90+
91+
const missing = [...upstream].filter((d) => !FIELD_DECORATORS.has(d));
92+
expect(
93+
missing,
94+
`@angular/core declares member decorator(s) the fast compiler does not ` +
95+
`extract: [${missing.join(', ')}]. Add them to FIELD_DECORATORS in ` +
96+
`compiler/constants.ts, or their host bindings / queries are dropped.`,
97+
).toEqual([]);
98+
});
8199
},
82100
);
101+
102+
describe('Angular decorator set consistency', () => {
103+
it('COMPILABLE_DECORATORS is a subset of ANGULAR_DECORATORS', () => {
104+
const notAngular = [...COMPILABLE_DECORATORS].filter(
105+
(d) => !ANGULAR_DECORATORS.has(d),
106+
);
107+
expect(notAngular).toEqual([]);
108+
});
109+
110+
it('excludes @Injectable / @Service from COMPILABLE_DECORATORS (they self-register via ɵprov)', () => {
111+
expect(COMPILABLE_DECORATORS.has('Injectable')).toBe(false);
112+
expect(COMPILABLE_DECORATORS.has('Service')).toBe(false);
113+
});
114+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import { compile } from './compile';
5+
import { inlineResourceUrls } from './resource-inliner';
6+
import type { ComponentRegistry } from './registry';
7+
8+
/**
9+
* Structural guard for the resource-inlining seam that the conformance suite
10+
* bypasses (it feeds inline code straight to `compile()`).
11+
*
12+
* A component authored with external `templateUrl` / `styleUrls` must emit the
13+
* exact same Ivy output as the same component authored inline. If the
14+
* resource-inlining pipeline ever diverges from the inline path (the class of
15+
* bug behind the external-`styleUrl` SCSS regression), the two outputs drift
16+
* and this test fails.
17+
*/
18+
const FIXTURES = path.join(__dirname, '__fixtures__');
19+
const ID = path.join(FIXTURES, 'test.component.ts');
20+
21+
function compileSource(src: string): string {
22+
const registry: ComponentRegistry = new Map();
23+
return compile(inlineResourceUrls(src, ID).code, ID, {
24+
registry,
25+
useDefineForClassFields: true,
26+
}).code;
27+
}
28+
29+
describe('resource inlining ↔ inline output parity', () => {
30+
it('emits identical Ivy for external templateUrl/styleUrls and their inline form', () => {
31+
const html = fs.readFileSync(
32+
path.join(FIXTURES, 'test.component.html'),
33+
'utf-8',
34+
);
35+
const css = fs.readFileSync(
36+
path.join(FIXTURES, 'test.component.css'),
37+
'utf-8',
38+
);
39+
40+
const external = `import { Component } from '@angular/core';
41+
@Component({
42+
selector: 'app-parity',
43+
templateUrl: './test.component.html',
44+
styleUrls: ['./test.component.css'],
45+
})
46+
export class ParityComponent {}
47+
`;
48+
49+
const inline = `import { Component } from '@angular/core';
50+
@Component({
51+
selector: 'app-parity',
52+
template: ${JSON.stringify(html)},
53+
styles: [${JSON.stringify(css)}],
54+
})
55+
export class ParityComponent {}
56+
`;
57+
58+
const externalOut = compileSource(external);
59+
const inlineOut = compileSource(inline);
60+
61+
// Guard against a vacuous pass: real Ivy must have been emitted for both.
62+
expect(externalOut).toContain('ɵcmp');
63+
expect(externalOut).toContain('ParityComponent');
64+
65+
expect(externalOut).toBe(inlineOut);
66+
});
67+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, it, expect } from 'vitest';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import { SIGNAL_APIS } from './constants';
5+
6+
/**
7+
* Drift detector for signal / initializer authoring APIs against upstream
8+
* `angular/angular`.
9+
*
10+
* The fast-compile path extracts metadata for signal-based members (inputs,
11+
* outputs, queries) by matching the initializer call against {@link SIGNAL_APIS}
12+
* (see `metadata.ts`). If Angular adds a new initializer API and the set does
13+
* not grow with it, members declared with the new API silently lose their
14+
* metadata — broken inputs/outputs/queries at runtime.
15+
*
16+
* ngtsc's canonical list lives in the `InitializerApiFunction['functionName']`
17+
* union, which is the authoritative source the Angular compiler itself uses.
18+
*/
19+
const ANGULAR_ROOT =
20+
process.env.ANGULAR_SOURCE_DIR ||
21+
path.resolve(process.env.HOME ?? '', 'projects/angular/angular');
22+
const INITIALIZER_FNS_FILE = path.join(
23+
ANGULAR_ROOT,
24+
'packages/compiler-cli/src/ngtsc/annotations/directive/src/initializer_functions.ts',
25+
);
26+
27+
function upstreamInitializerApis(): Set<string> {
28+
const code = fs.readFileSync(INITIALIZER_FNS_FILE, 'utf-8');
29+
const union = code.match(/functionName:\s*([^;]+);/);
30+
const names = new Set<string>();
31+
if (union) {
32+
for (const m of union[1].matchAll(/'([A-Za-z]+)'/g)) names.add(m[1]);
33+
}
34+
return names;
35+
}
36+
37+
describe.skipIf(!fs.existsSync(INITIALIZER_FNS_FILE))(
38+
'Angular signal/initializer API coverage (upstream drift)',
39+
() => {
40+
it('SIGNAL_APIS covers every initializer API ngtsc recognizes', () => {
41+
const upstream = upstreamInitializerApis();
42+
43+
// Guard against a vacuous pass if the upstream union shape changed.
44+
expect(
45+
upstream.has('input') && upstream.has('output'),
46+
`No initializer APIs parsed from ${INITIALIZER_FNS_FILE}; the upstream ` +
47+
`\`InitializerApiFunction\` shape changed — update this detector.`,
48+
).toBe(true);
49+
50+
const missing = [...upstream].filter((api) => !SIGNAL_APIS.has(api));
51+
expect(
52+
missing,
53+
`ngtsc recognizes initializer API(s) the fast compiler does not ` +
54+
`extract: [${missing.join(', ')}]. Add them to SIGNAL_APIS in ` +
55+
`compiler/constants.ts, or signal inputs/outputs/queries declared ` +
56+
`with them lose their metadata.`,
57+
).toEqual([]);
58+
});
59+
},
60+
);

0 commit comments

Comments
 (0)