diff --git a/.gitignore b/.gitignore index 79df56493b..db3d62b944 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,9 @@ src/index.ts src/test-utils/dom/index.ts src/test-utils/selectors src/icon/generated -src/internal/generated/custom-css-properties/index.* +src/internal/generated/custom-css-properties vendor/generated-*.txt # IDEs .vscode # System -.DS_Store \ No newline at end of file +.DS_Store diff --git a/build-tools/tasks/clean.js b/build-tools/tasks/clean.js index bc7c2ac143..d7ded376a9 100644 --- a/build-tools/tasks/clean.js +++ b/build-tools/tasks/clean.js @@ -14,6 +14,7 @@ module.exports = task('clean', () => { `${workspace.targetPath}/**`, `${workspace.staticSitePath}/**`, `${workspace.generatedTestUtils}/**`, + `${workspace.generatedPath}/custom-css-properties/**`, `node_modules/.cache`, ], { glob: true } diff --git a/build-tools/tasks/generate-custom-css-properties.js b/build-tools/tasks/generate-custom-css-properties.js index a0fd2e9d4e..b246cca97f 100644 --- a/build-tools/tasks/generate-custom-css-properties.js +++ b/build-tools/tasks/generate-custom-css-properties.js @@ -1,11 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -const customCssPropertiesList = require('../../src/internal/generated/custom-css-properties/list'); const path = require('path'); -const { writeFile } = require('../utils/files'); const { getHashDigest } = require('loader-utils'); +const customCssPropertiesList = require('../utils/custom-css-properties'); +const { writeFile } = require('../utils/files'); +const workspace = require('../utils/workspace'); -const outputBasePath = path.join(__dirname, '../../src/internal/generated/custom-css-properties'); +const outputBasePath = path.join(workspace.generatedPath, 'custom-css-properties'); const hash = getHashDigest(Buffer.from(JSON.stringify(customCssPropertiesList)), 'md5', 'base36', 6); const getHashedProperty = property => { @@ -32,6 +33,9 @@ function writeSassFile() { writeFile( filepath, ` + // Build environment + $awsui-commit-hash: ${workspace.gitCommitVersion}; + // Manually managed CSS-variables ${customCssPropertiesList.map(property => `$${property}: ${getHashedProperty(property)};`).join('\n')} ` ); diff --git a/build-tools/tasks/generate-environment.js b/build-tools/tasks/generate-environment.js index 4d28f833e0..f706c039fe 100644 --- a/build-tools/tasks/generate-environment.js +++ b/build-tools/tasks/generate-environment.js @@ -10,6 +10,7 @@ function writeEnvironmentFile(theme) { const values = { PACKAGE_SOURCE: workspace.packageSource, PACKAGE_VERSION: workspace.packageVersion, + GIT_SHA: workspace.gitCommitVersion, THEME: theme.name, ALWAYS_VISUAL_REFRESH: !!theme.alwaysVisualRefresh, }; diff --git a/src/internal/generated/custom-css-properties/list.js b/build-tools/utils/custom-css-properties.js similarity index 100% rename from src/internal/generated/custom-css-properties/list.js rename to build-tools/utils/custom-css-properties.js diff --git a/build-tools/utils/workspace.js b/build-tools/utils/workspace.js index 7283d5b8d7..4c10a2cd75 100644 --- a/build-tools/utils/workspace.js +++ b/build-tools/utils/workspace.js @@ -11,6 +11,7 @@ module.exports = { isProd: process.env.NODE_ENV === 'production', packageSource, packageVersion, + gitCommitVersion, sourcePath: 'src', generatedPath: 'src/internal/generated', generatedTestUtils: 'src/test-utils/selectors', diff --git a/src/__integ__/__snapshots__/themes.test.ts.snap b/src/__integ__/__snapshots__/themes.test.ts.snap index f591c9d5fe..6489bf42b9 100644 --- a/src/__integ__/__snapshots__/themes.test.ts.snap +++ b/src/__integ__/__snapshots__/themes.test.ts.snap @@ -2,6 +2,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "2px", "border-code-editor-status-divider-width": "0px", "border-container-sticky-width": "1px", @@ -679,6 +680,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "compact" 1`] = exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "2px", "border-code-editor-status-divider-width": "0px", "border-container-sticky-width": "1px", @@ -1356,6 +1358,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "dark" 1`] = ` exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "2px", "border-code-editor-status-divider-width": "0px", "border-container-sticky-width": "1px", @@ -2033,6 +2036,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "light" 1`] = ` exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "2px", "border-code-editor-status-divider-width": "0px", "border-container-sticky-width": "1px", @@ -2710,6 +2714,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "reduced-motion" exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "4px", "border-code-editor-status-divider-width": "1px", "border-container-sticky-width": "0px", @@ -3387,6 +3392,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh" exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-compact" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "4px", "border-code-editor-status-divider-width": "1px", "border-container-sticky-width": "0px", @@ -4064,6 +4070,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-content-header" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "4px", "border-code-editor-status-divider-width": "1px", "border-container-sticky-width": "0px", @@ -4741,6 +4748,7 @@ exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh- exports[`CSS Custom Properties match previous snapshot for mode "visual-refresh-dark" 1`] = ` { + "awsui-version-info": "true", "border-active-width": "4px", "border-code-editor-status-divider-width": "1px", "border-container-sticky-width": "0px", diff --git a/src/__integ__/themes.test.ts b/src/__integ__/themes.test.ts index 08eed7ae33..773571a633 100644 --- a/src/__integ__/themes.test.ts +++ b/src/__integ__/themes.test.ts @@ -23,7 +23,7 @@ class CustomPropertyPageObject extends BasePageObject { for (const [prop, value] of (element as any).computedStyleMap()) { // Custom Property if (prop.startsWith('--')) { - const valueWithoutPostfix = prop.substring(2, prop.length - 7); + const valueWithoutPostfix = prop.replace(/^--/, '').replace(/-[\d\w]+$/, ''); result[valueWithoutPostfix] = value[0][0]; } } diff --git a/src/internal/base-component/styles.scss b/src/internal/base-component/styles.scss index 66c1e86fee..158fa6d799 100644 --- a/src/internal/base-component/styles.scss +++ b/src/internal/base-component/styles.scss @@ -9,3 +9,8 @@ */ @use 'awsui:globals'; @use '../styles/global.scss'; +@use '../generated/custom-css-properties' as custom-styles; + +:root { + --awsui-version-info-#{custom-styles.$awsui-commit-hash}: true; +} diff --git a/src/internal/environment.d.ts b/src/internal/environment.d.ts index 450933dec0..1db77857f7 100644 --- a/src/internal/environment.d.ts +++ b/src/internal/environment.d.ts @@ -5,5 +5,6 @@ export const THEME: string; export const PACKAGE_SOURCE: string; export const PACKAGE_VERSION: string; +export const GIT_SHA: string; /** Indicates that the current theme is always in visual refresh mode. */ export const ALWAYS_VISUAL_REFRESH: boolean; diff --git a/src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx b/src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx new file mode 100644 index 0000000000..c1716e1743 --- /dev/null +++ b/src/internal/hooks/use-base-component/__tests__/styles-check-hook.test.tsx @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; + +import { useMissingStylesCheck } from '../../../../../lib/components/internal/hooks/use-base-component/styles-check'; + +function Test() { + useMissingStylesCheck(); + return null; +} + +let consoleMock: jest.SpyInstance; +beforeEach(() => { + globalThis.requestIdleCallback = cb => setTimeout(cb, 0); + consoleMock = jest.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('emits style check error only once', async () => { + const { rerender } = render(); + + await waitFor( + () => { + expect(consoleMock).toHaveBeenCalledTimes(1); + expect(consoleMock).toHaveBeenCalledWith(expect.stringContaining('Missing AWS-UI CSS')); + }, + { timeout: 2000 } + ); + + consoleMock.mockClear(); + rerender(); + + await new Promise(resolve => setTimeout(resolve, 1100)); + expect(consoleMock).toHaveBeenCalledTimes(0); +}); diff --git a/src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts b/src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts new file mode 100644 index 0000000000..34b4412759 --- /dev/null +++ b/src/internal/hooks/use-base-component/__tests__/styles-check-pure.test.ts @@ -0,0 +1,111 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GIT_SHA } from '../../../../../lib/components/internal/environment'; +import { checkMissingStyles } from '../../../../../lib/components/internal/hooks/use-base-component/styles-check'; +import { metrics } from '../../../../../lib/components/internal/metrics'; +import { idleWithDelay } from '../styles-check'; + +jest.mock('../../../../../lib/components/internal/environment', () => ({ + ...jest.requireActual('../../../../../lib/components/internal/environment'), + PACKAGE_VERSION: '3.0.0 (abc)', + GIT_SHA: 'abc', +})); + +afterEach(() => { + jest.resetAllMocks(); +}); + +describe('checkMissingStyles', () => { + let consoleWarnSpy: jest.SpyInstance; + let sendPanoramaMetricSpy: jest.SpyInstance; + const style = document.createElement('style'); + document.body.append(style); + + beforeEach(() => { + style.textContent = ``; + consoleWarnSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + sendPanoramaMetricSpy = jest.spyOn(metrics, 'sendOpsMetricObject').mockImplementation(() => {}); + }); + + test('should pass the check if styles found', () => { + // using :root does not work in JSDOM: https://github.com/jsdom/jsdom/issues/3563 + style.textContent = ` + body { + --awsui-version-info-${GIT_SHA}: true; + } + `; + checkMissingStyles(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(sendPanoramaMetricSpy).not.toHaveBeenCalled(); + }); + + test('should detect missing styles', () => { + checkMissingStyles(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Missing AWS-UI CSS for theme "default", version "3.0.0 (abc)", and git sha "abc".' + ); + expect(sendPanoramaMetricSpy).toHaveBeenCalledWith('awsui-missing-css-asset', {}); + }); + + test('should report missing styles if a different version found', () => { + style.textContent = ` + body { + --awsui-version-info-c4d5e6: true; + } + `; + checkMissingStyles(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Missing AWS-UI CSS for theme "default", version "3.0.0 (abc)", and git sha "abc".' + ); + expect(sendPanoramaMetricSpy).toHaveBeenCalledWith('awsui-missing-css-asset', {}); + }); +}); + +describe('idleWithDelay', () => { + beforeEach(() => { + jest.useFakeTimers(); + // simulate requestIdleCallback for JSDOM + globalThis.requestIdleCallback = cb => setTimeout(cb, 0); + }); + + afterEach(() => { + jest.useRealTimers(); + // @ts-expect-error reset to initial state + globalThis.requestIdleCallback = undefined; + }); + + test('does nothing if requestIdleCallback not supported', () => { + // @ts-expect-error simulate missing API + globalThis.requestIdleCallback = undefined; + const cb = jest.fn(); + expect(requestIdleCallback).toBe(undefined); + idleWithDelay(cb); + jest.runAllTimers(); + expect(cb).not.toHaveBeenCalled(); + }); + + test('runs callback after a delay', () => { + const cb = jest.fn(); + idleWithDelay(cb); + jest.runAllTimers(); + expect(cb).toHaveBeenCalled(); + }); + + test('delay can be aborted before setTimeout phase', () => { + const cb = jest.fn(); + const abort = idleWithDelay(cb); + abort!(); + jest.runAllTimers(); + expect(cb).not.toHaveBeenCalled(); + }); + + test('delay can be aborted before requestIdleCallback phase', () => { + const cb = jest.fn(); + const abort = idleWithDelay(cb); + jest.runOnlyPendingTimers(); // flush setTimeout + abort!(); + jest.runOnlyPendingTimers(); // flush following requestIdleCallback + expect(cb).not.toHaveBeenCalled(); + }); +}); diff --git a/src/internal/hooks/use-base-component/index.ts b/src/internal/hooks/use-base-component/index.ts index 86af7ddc9f..992785b4bf 100644 --- a/src/internal/hooks/use-base-component/index.ts +++ b/src/internal/hooks/use-base-component/index.ts @@ -13,6 +13,7 @@ import { AnalyticsMetadata } from '../../analytics/interfaces'; import { PACKAGE_SOURCE, PACKAGE_VERSION, THEME } from '../../environment'; import { getVisualTheme } from '../../utils/get-visual-theme'; import { useVisualRefresh } from '../use-visual-mode'; +import { useMissingStylesCheck } from './styles-check'; export interface InternalBaseComponentProps { __internalRootRef?: MutableRefObject | null; @@ -32,6 +33,7 @@ export default function useBaseComponent( const theme = getVisualTheme(THEME, isVisualRefresh); useComponentMetrics(componentName, { packageSource: PACKAGE_SOURCE, packageVersion: PACKAGE_VERSION, theme }, config); useFocusVisible(); + useMissingStylesCheck(); const elementRef = useComponentMetadata( componentName, { packageName: PACKAGE_SOURCE, version: PACKAGE_VERSION, theme }, diff --git a/src/internal/hooks/use-base-component/styles-check.ts b/src/internal/hooks/use-base-component/styles-check.ts new file mode 100644 index 0000000000..8d17a19aa8 --- /dev/null +++ b/src/internal/hooks/use-base-component/styles-check.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useEffect } from 'react'; + +import { GIT_SHA, PACKAGE_VERSION, THEME } from '../../environment'; +import { metrics } from '../../metrics'; + +export function checkMissingStyles() { + const result = getComputedStyle(document.body).getPropertyValue(`--awsui-version-info-${GIT_SHA}`); + if (!result) { + console.error(`Missing AWS-UI CSS for theme "${THEME}", version "${PACKAGE_VERSION}", and git sha "${GIT_SHA}".`); + metrics.sendOpsMetricObject('awsui-missing-css-asset', {}); + } +} + +export function idleWithDelay(cb: () => void) { + // if idle callbacks not supported, we simply do not collect the metric + if (typeof requestIdleCallback !== 'function') { + return; + } + let aborted = false; + + setTimeout(() => { + if (aborted) { + return; + } + requestIdleCallback(() => { + if (aborted) { + return; + } + cb(); + }); + }, 1000); + + return () => { + aborted = true; + }; +} + +let checked = false; +const checkMissingStylesOnce = () => { + if (!checked) { + checkMissingStyles(); + checked = true; + } +}; + +export function useMissingStylesCheck() { + useEffect(() => { + return idleWithDelay(() => checkMissingStylesOnce()); + }, []); +} diff --git a/src/internal/metrics.ts b/src/internal/metrics.ts index 0942aee508..b35d9eea2f 100644 --- a/src/internal/metrics.ts +++ b/src/internal/metrics.ts @@ -2,6 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { Metrics } from '@cloudscape-design/component-toolkit/internal'; -import { PACKAGE_SOURCE, PACKAGE_VERSION } from './environment'; +import { PACKAGE_SOURCE, PACKAGE_VERSION, THEME } from './environment'; -export const metrics = new Metrics(PACKAGE_SOURCE, PACKAGE_VERSION); +export const metrics = new Metrics({ packageSource: PACKAGE_SOURCE, packageVersion: PACKAGE_VERSION, theme: THEME });