fix(tools): count runtime Material imports in the theme guard and scope the dialog motion check

The theme-scope guard matched any `from '@angular/material/<entry>'`, so a
type-only import kept a theme required and a dynamic `import()` (as
LazyMatDialog makes) was not counted at all. It now reads the
comment-stripped source for static imports that are not `import type`
and for dynamic imports, with tests for both.

The motion spec accepted any transform transition on the page as the
dialog's entrance and checked no exit motion. Events now carry their
target's class list: the entrance is the surface's transform transition
recorded since the dialog opened, and the close must start the inner
container's opacity fade before the container is removed. The fade's end
is not asserted: Material detaches the container on a timer of the same
length, so a detached element may never report it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-13 18:31:33 +02:00
1 parent 85e979ccf0
commit 7917040aab
3 files changed
+85 -11

No files matched your search

@@ -25,6 +25,8 @@ import {
interface MotionEvent {
type: string;
name: string;
/** The class attribute of the element the event fired on. */
target: string;
}
type MotionWindow = Window & { __motionEvents?: MotionEvent[] };
@@ -43,11 +45,16 @@ async function recordMotion(page: Page): Promise<void> {
document.addEventListener(
type,
(event) => {
const target = event.target;
events.push({
type,
name:
(event as AnimationEvent).animationName ??
(event as TransitionEvent).propertyName,
target:
target instanceof Element
? (target.getAttribute('class') ?? '')
: '',
});
},
true
@@ -60,22 +67,37 @@ async function motionEvents(page: Page): Promise<MotionEvent[]> {
return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []);
}
/**
* Waits for an event of that type and name, on an element wearing
* `targetClass` when given, among the events recorded from `since` on.
*/
async function expectMotion(
page: Page,
type: string,
name: string
name: string,
{ targetClass, since = 0 }: { targetClass?: string; since?: number } = {}
): Promise<void> {
await expect
.poll(
async () =>
(await motionEvents(page)).some(
(event) => event.type === type && event.name === name
),
`${type} ${name}`
(await motionEvents(page))
.slice(since)
.some(
(event) =>
event.type === type &&
event.name === name &&
(targetClass === undefined ||
event.target.split(/\s+/).includes(targetClass))
),
`${type} ${name}${targetClass ? ` on .${targetClass}` : ''}`
)
.toBe(true);
}
async function recordedCount(page: Page): Promise<number> {
return (await motionEvents(page)).length;
}
/** Opens the Clear EPG data confirmation from the EPG settings section. */
async function openClearEpgDialog(page: Page) {
await openSettingsSection(page, 'epg');
@@ -148,6 +170,7 @@ test.describe('Material motion without the animations module', () => {
// Dialog: Material drives it with transitions whose duration it
// writes on the container; the surface scales in. Confirming
// closes it after the exit transition and shows a snack-bar.
const beforeDialog = await recordedCount(page);
const dialog = await openClearEpgDialog(page);
await expect(dialog).not.toHaveClass(/_mat-animation-noopable/);
await expect(dialog).toHaveClass(/mdc-dialog--open/);
@@ -158,8 +181,20 @@ test.describe('Material motion without the animations module', () => {
)
)
).toMatch(/^[1-9]\d*ms$/);
await expectMotion(page, 'transitionend', 'transform');
// The surface scales in while opening.
await expectMotion(page, 'transitionend', 'transform', {
targetClass: 'mat-mdc-dialog-surface',
since: beforeDialog,
});
const beforeClose = await recordedCount(page);
await dialog.getByTestId('confirm-dialog-confirm').click();
// Closing fades the inner container out. Material detaches the
// container on a timer of the same length, so the fade's end
// may never be observed; its start is, in the first frame.
await expectMotion(page, 'transitionrun', 'opacity', {
targetClass: 'mat-mdc-dialog-inner-container',
since: beforeClose,
});
await expect(dialog).toHaveCount(0);
const snackBar = page.locator('.mat-mdc-snack-bar-container');
+20 -5
View File
@@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isScanned } from './check-theme-references.mjs';
import { isScanned, stripComments } from './check-theme-references.mjs';
/**
* `apps/web/src/m3-theme.scss` themes only the Material components the app
@@ -20,7 +20,16 @@ const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
export const THEME_FILE = 'apps/web/src/m3-theme.scss';
const ENTRY_POINT_IMPORT = /from\s+['"]@angular\/material\/([a-z-]+)['"]/g;
/**
* Runtime imports of a Material entry point: a static import that is not
* type-only (a `type` specifier list leaves nothing in the bundle) and a
* dynamic `import()` such as the one LazyMatDialog makes. A type-only
* import renders no component and so needs no theme.
*/
const STATIC_IMPORT =
/(?<![\w$.])import\s+(?!type\s)[^;]*?from\s+['"]@angular\/material\/([a-z-]+)['"]/g;
const DYNAMIC_IMPORT =
/\bimport\(\s*['"]@angular\/material\/([a-z-]+)['"]\s*\)/g;
/**
* The theme mixins (`mat.<name>-theme`, `mat.<name>-color`) each Material
@@ -72,12 +81,18 @@ export const THEMES_BY_ENTRY_POINT = new Map([
*/
export const ALWAYS_THEMED = ['core'];
/** Material entry points the runtime sources import, with one importer each. */
/**
* Material entry points the runtime sources import at runtime, statically or
* dynamically, with one importer each.
*/
export function usedEntryPoints(sources) {
const importers = new Map();
for (const { file, source } of sources) {
for (const match of source.matchAll(ENTRY_POINT_IMPORT)) {
if (!importers.has(match[1])) importers.set(match[1], file);
const code = stripComments(source);
for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) {
for (const match of code.matchAll(pattern)) {
if (!importers.has(match[1])) importers.set(match[1], file);
}
}
}
return importers;
@@ -38,6 +38,30 @@ test('usedEntryPoints lists each Material entry point with its first importer',
);
});
test('usedEntryPoints counts dynamic imports and ignores type-only ones and comments', () => {
const importers = usedEntryPoints([
{
file: 'lazy-mat-dialog.ts',
source: lines(
"import type { MatDialog } from '@angular/material/dialog';",
"type DialogModule = typeof import('@angular/material/dialog');",
'loadDialogModule = () =>',
" import('@angular/material/dialog');"
),
},
{
file: 'types-only.ts',
source: lines(
"import type { MatTooltip } from '@angular/material/tooltip';",
"// import { MatChipsModule } from '@angular/material/chips';",
"/* import { MatTabsModule } from '@angular/material/tabs'; */"
),
},
]);
assert.deepEqual([...importers], [['dialog', 'lazy-mat-dialog.ts']]);
});
test('requiredThemes always keeps core and expands the button entry point', () => {
const required = requiredThemes(
new Map([