77 */
88
99import { buildApplication } from '../../index' ;
10+ import { OutputHashing } from '../../schema' ;
1011import { APPLICATION_BUILDER_INFO , BASE_OPTIONS , describeBuilder } from '../setup' ;
1112
13+ /** Returns a copy of the value with object keys sorted at every level. Arrays keep their order. */
14+ function sortKeys ( value : unknown ) : unknown {
15+ if ( Array . isArray ( value ) ) {
16+ return value . map ( sortKeys ) ;
17+ }
18+ if ( value && typeof value === 'object' ) {
19+ const record = value as Record < string , unknown > ;
20+
21+ return Object . fromEntries (
22+ Object . keys ( record )
23+ . sort ( )
24+ . map ( ( key ) => [ key , sortKeys ( record [ key ] ) ] ) ,
25+ ) ;
26+ }
27+
28+ return value ;
29+ }
30+
31+ /** A standalone component in `src/app/<dir>` that uses the stylesheet `src/app/<dir>/shared.css`. */
32+ function sharedStylesheetComponent ( dir : string ) : string {
33+ return `
34+ import { Component } from '@angular/core';
35+ @Component({
36+ selector: 'app-shared-${ dir } ',
37+ template: '<p>${ dir } </p>',
38+ styleUrl: './shared.css',
39+ })
40+ export class Shared${ dir . toUpperCase ( ) } Component {}
41+ ` ;
42+ }
43+
1244describeBuilder ( buildApplication , APPLICATION_BUILDER_INFO , ( harness ) => {
1345 describe ( 'Option: "statsJson"' , ( ) => {
1446 it ( 'generates only browser stats file containing valid metafile data when true' , async ( ) => {
@@ -52,60 +84,128 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
5284 harness . expectFile ( 'dist/server-stats.json' ) . toNotExist ( ) ;
5385 } ) ;
5486
55- it ( 'attributes a shared component stylesheet output to every input on every build' , async ( ) => {
56- // Two components in different directories with byte-identical stylesheets of the same
57- // file name bundle to the same output file. The merged metafile must list both inputs.
58- const component = ( dir : string ) => `
59- import { Component } from '@angular/core';
60- @Component({
61- selector: 'app-shared-${ dir } ',
62- template: '<p>${ dir } </p>',
63- styleUrl: './shared.css',
64- })
65- export class Shared${ dir . toUpperCase ( ) } Component {}
66- ` ;
67- await harness . writeFiles ( {
68- 'src/app/a/shared.component.ts' : component ( 'a' ) ,
69- 'src/app/a/shared.css' : 'p { color: red; }' ,
70- 'src/app/b/shared.component.ts' : component ( 'b' ) ,
71- 'src/app/b/shared.css' : 'p { color: red; }' ,
72- } ) ;
73- await harness . writeFile (
74- 'src/app/app.module.ts' ,
75- `
76- import { NgModule } from '@angular/core';
77- import { BrowserModule } from '@angular/platform-browser';
78- import { AppComponent } from './app.component';
79- import { SharedAComponent } from './a/shared.component';
80- import { SharedBComponent } from './b/shared.component';
81- @NgModule({
82- declarations: [AppComponent],
83- imports: [BrowserModule, SharedAComponent, SharedBComponent],
84- bootstrap: [AppComponent],
85- })
86- export class AppModule {}
87- ` ,
88- ) ;
89- await harness . writeFile (
90- 'src/app/app.component.html' ,
91- '<app-shared-a></app-shared-a><app-shared-b></app-shared-b>' ,
92- ) ;
93-
87+ it ( 'writes the stats file with sorted keys' , async ( ) => {
9488 harness . useTarget ( 'build' , {
9589 ...BASE_OPTIONS ,
9690 statsJson : true ,
9791 } ) ;
9892
99- for ( let i = 0 ; i < 2 ; i ++ ) {
100- const { result } = await harness . executeOnce ( ) ;
101- expect ( result ?. success ) . toBeTrue ( ) ;
93+ const { result } = await harness . executeOnce ( ) ;
94+ expect ( result ?. success ) . toBeTrue ( ) ;
95+
96+ const text = harness . readFile ( 'dist/browser-stats.json' ) ;
97+ expect ( text ) . toEqual ( JSON . stringify ( sortKeys ( JSON . parse ( text ) ) , null , 2 ) ) ;
98+ } ) ;
99+
100+ describe ( 'shared component stylesheet' , ( ) => {
101+ beforeEach ( async ( ) => {
102+ // Two components in different directories with byte-identical stylesheets of the same
103+ // file name bundle to the same output file.
104+ await harness . writeFiles ( {
105+ 'src/app/a/shared.component.ts' : sharedStylesheetComponent ( 'a' ) ,
106+ 'src/app/a/shared.css' : 'p { color: red; }' ,
107+ 'src/app/b/shared.component.ts' : sharedStylesheetComponent ( 'b' ) ,
108+ 'src/app/b/shared.css' : 'p { color: red; }' ,
109+ 'src/app/app.component.html' :
110+ '<app-shared-a></app-shared-a><app-shared-b></app-shared-b>' ,
111+ } ) ;
112+ await harness . writeFile (
113+ 'src/app/app.module.ts' ,
114+ `
115+ import { NgModule } from '@angular/core';
116+ import { BrowserModule } from '@angular/platform-browser';
117+ import { AppComponent } from './app.component';
118+ import { SharedAComponent } from './a/shared.component';
119+ import { SharedBComponent } from './b/shared.component';
120+ @NgModule({
121+ declarations: [AppComponent],
122+ imports: [BrowserModule, SharedAComponent, SharedBComponent],
123+ bootstrap: [AppComponent],
124+ })
125+ export class AppModule {}
126+ ` ,
127+ ) ;
128+ } ) ;
129+
130+ it ( 'attributes a shared component stylesheet output to every input on every build' , async ( ) => {
131+ harness . useTarget ( 'build' , {
132+ ...BASE_OPTIONS ,
133+ statsJson : true ,
134+ } ) ;
135+
136+ for ( let i = 0 ; i < 2 ; i ++ ) {
137+ const { result } = await harness . executeOnce ( ) ;
138+ expect ( result ?. success ) . toBeTrue ( ) ;
139+
140+ const stats = JSON . parse ( harness . readFile ( 'dist/browser-stats.json' ) ) ;
141+ expect ( Object . keys ( stats . outputs [ 'shared.css' ] . inputs ) ) . toEqual ( [
142+ 'src/app/a/shared.css' ,
143+ 'src/app/b/shared.css' ,
144+ ] ) ;
145+ }
146+ } ) ;
102147
103- const stats = JSON . parse ( harness . readFile ( 'dist/browser-stats.json' ) ) ;
104- expect ( Object . keys ( stats . outputs [ 'shared.css' ] . inputs ) ) . toEqual ( [
105- 'src/app/a/shared.css' ,
106- 'src/app/b/shared.css' ,
148+ it ( 're-attributes a shared component stylesheet output after one stylesheet changes' , async ( ) => {
149+ // Hashed output names keep the two stylesheets apart once their content differs, so the
150+ // outputs are found by their inputs. Minified styles carry no source path comment, which
151+ // gives the identical stylesheets the same hashed name.
152+ harness . useTarget ( 'build' , {
153+ ...BASE_OPTIONS ,
154+ statsJson : true ,
155+ optimization : { styles : true } ,
156+ outputHashing : OutputHashing . All ,
157+ watch : true ,
158+ } ) ;
159+
160+ const inputsOfOutputFor = ( stylesheet : string ) : string [ ] [ ] => {
161+ const stats = JSON . parse ( harness . readFile ( 'dist/browser-stats.json' ) ) ;
162+
163+ return Object . values < { inputs : Record < string , unknown > } > ( stats . outputs )
164+ . map ( ( output ) => Object . keys ( output . inputs ) )
165+ . filter ( ( inputs ) => inputs . includes ( stylesheet ) ) ;
166+ } ;
167+
168+ await harness . executeWithCases ( [
169+ async ( { result } ) => {
170+ expect ( result ?. success ) . toBeTrue ( ) ;
171+ expect ( inputsOfOutputFor ( 'src/app/b/shared.css' ) ) . toEqual ( [
172+ [ 'src/app/a/shared.css' , 'src/app/b/shared.css' ] ,
173+ ] ) ;
174+
175+ await harness . writeFile ( 'src/app/a/shared.css' , 'p { color: blue; }' ) ;
176+ } ,
177+ async ( { result } ) => {
178+ expect ( result ?. success ) . toBeTrue ( ) ;
179+ expect ( inputsOfOutputFor ( 'src/app/b/shared.css' ) ) . toEqual ( [ [ 'src/app/b/shared.css' ] ] ) ;
180+ expect ( inputsOfOutputFor ( 'src/app/a/shared.css' ) ) . toEqual ( [ [ 'src/app/a/shared.css' ] ] ) ;
181+
182+ await harness . writeFile ( 'src/app/a/shared.css' , 'p { color: red; }' ) ;
183+ } ,
184+ async ( { result } ) => {
185+ expect ( result ?. success ) . toBeTrue ( ) ;
186+ expect ( inputsOfOutputFor ( 'src/app/b/shared.css' ) ) . toEqual ( [
187+ [ 'src/app/a/shared.css' , 'src/app/b/shared.css' ] ,
188+ ] ) ;
189+
190+ // The restore re-bundled only `a`, so `b` now merges first. A second change to `a`
191+ // checks that the merge left the cached `b` result untouched.
192+ await harness . writeFile ( 'src/app/a/shared.css' , 'p { color: green; }' ) ;
193+ } ,
194+ async ( { result } ) => {
195+ expect ( result ?. success ) . toBeTrue ( ) ;
196+ expect ( inputsOfOutputFor ( 'src/app/b/shared.css' ) ) . toEqual ( [ [ 'src/app/b/shared.css' ] ] ) ;
197+ expect ( inputsOfOutputFor ( 'src/app/a/shared.css' ) ) . toEqual ( [ [ 'src/app/a/shared.css' ] ] ) ;
198+
199+ await harness . writeFile ( 'src/app/a/shared.css' , 'p { color: red; }' ) ;
200+ } ,
201+ ( { result } ) => {
202+ expect ( result ?. success ) . toBeTrue ( ) ;
203+ expect ( inputsOfOutputFor ( 'src/app/b/shared.css' ) ) . toEqual ( [
204+ [ 'src/app/a/shared.css' , 'src/app/b/shared.css' ] ,
205+ ] ) ;
206+ } ,
107207 ] ) ;
108- }
208+ } ) ;
109209 } ) ;
110210
111211 describe ( 'server build' , ( ) => {
@@ -119,6 +219,25 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
119219 } ) ;
120220 } ) ;
121221
222+ it ( 'writes both stats files with sorted keys' , async ( ) => {
223+ harness . useTarget ( 'build' , {
224+ ...BASE_OPTIONS ,
225+ server : 'src/main.server.ts' ,
226+ ssr : true ,
227+ statsJson : true ,
228+ } ) ;
229+
230+ const { result } = await harness . executeOnce ( ) ;
231+ expect ( result ?. success ) . toBeTrue ( ) ;
232+
233+ for ( const file of [ 'dist/browser-stats.json' , 'dist/server-stats.json' ] ) {
234+ const text = harness . readFile ( file ) ;
235+ expect ( text )
236+ . withContext ( file )
237+ . toEqual ( JSON . stringify ( sortKeys ( JSON . parse ( text ) ) , null , 2 ) ) ;
238+ }
239+ } ) ;
240+
122241 it ( 'generates separated browser and server stats files for an SSR build' , async ( ) => {
123242 harness . useTarget ( 'build' , {
124243 ...BASE_OPTIONS ,
0 commit comments