diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1f40233b7325..10cd1022b98f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,6 +25,7 @@ pnpm run test-jest # jest jsdom (from packages/devextreme pnpm run test-jest:all # jest jsdom + node pnpm nx test devextreme-testcafe-tests # TestCafe e2e pnpm nx test devextreme-angular # wrapper tests (also -react, -vue) +pnpm nx test:timezones devextreme # verify timezone list against moment-timezone # Lint pnpm nx run-many -t lint # all packages diff --git a/packages/devextreme/project.json b/packages/devextreme/project.json index 57272c8b1b82..ab649868bb42 100644 --- a/packages/devextreme/project.json +++ b/packages/devextreme/project.json @@ -1109,6 +1109,16 @@ ] } }, + "test:timezones": { + "executor": "devextreme-nx-infra-plugin:test-timezones", + "options": { + "timezoneListFile": "./js/__internal/scheduler/timezones/timezone_list.ts", + "momentTimezoneUrl": "https://raw.githubusercontent.com/moment/moment-timezone/develop/data/unpacked/latest.json" + }, + "inputs": [ + "{projectRoot}/js/__internal/scheduler/timezones/timezone_list.ts" + ] + }, "copy:vendor:js": { "executor": "devextreme-nx-infra-plugin:copy-files", "options": { diff --git a/packages/nx-infra-plugin/AGENTS.md b/packages/nx-infra-plugin/AGENTS.md index bf94cd2d9d9e..fb026621c21e 100644 --- a/packages/nx-infra-plugin/AGENTS.md +++ b/packages/nx-infra-plugin/AGENTS.md @@ -55,6 +55,13 @@ Each behavior is owned by exactly ONE executor's canonical tests; consumers must 2. Register in `executors.json`: `implementation: ./src/executors//executor`, `schema: ./src/executors//schema.json`. 3. Validate: tsc → jest → lint. All tests must still pass. +## Notable executors + +| Executor | Target example | Purpose | +|----------|---------------|---------| +| `license-check` | `verify:licenses` | Verify embedded license notices in built artifacts | +| `test-timezones` | `test:timezones` | Fetch latest moment-timezone data and verify the bundled timezone list contains only valid IANA identifiers | + ## Refactor an existing executor 1. Run `grep -rn "" src/executors/`. If 3+ executors share a pattern, it is a centralization candidate. diff --git a/packages/nx-infra-plugin/executors.json b/packages/nx-infra-plugin/executors.json index 8672e3559e1c..9cef3dbb3d03 100644 --- a/packages/nx-infra-plugin/executors.json +++ b/packages/nx-infra-plugin/executors.json @@ -119,6 +119,11 @@ "implementation": "./src/executors/state-manager-optimize/executor", "schema": "./src/executors/state-manager-optimize/schema.json", "description": "Optimize state_manager modules for production builds" + }, + "test-timezones": { + "implementation": "./src/executors/test-timezones/executor", + "schema": "./src/executors/test-timezones/schema.json", + "description": "Verify bundled timezone list against latest moment-timezone data" } } } diff --git a/packages/nx-infra-plugin/src/executors/test-timezones/executor.e2e.spec.ts b/packages/nx-infra-plugin/src/executors/test-timezones/executor.e2e.spec.ts new file mode 100644 index 000000000000..ed212e0c2b2f --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/test-timezones/executor.e2e.spec.ts @@ -0,0 +1,210 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import executor from './executor'; +import { TestTimezonesExecutorSchema } from './schema'; +import { createTempDir, cleanupTempDir, createMockContext } from '../../utils/test-utils'; +import { writeFileText } from '../../utils'; +import { extractTimezoneList, validateTimezoneList } from './test-timezones.impl'; + +const VALID_TIMEZONE_LIST_CONTENT = `export default { + value: [ + 'America/New_York', + 'America/Los_Angeles', + 'Europe/London', + 'Asia/Tokyo', + ], +}; +`; + +const INVALID_TIMEZONE_LIST_CONTENT = `export default { + value: [ + 'America/New_York', + 'Invalid/Timezone', + 'Europe/London', + 'Fake/Zone', + ], +}; +`; + +const MOCK_MOMENT_DATA = { + version: '2024a', + zones: [ + { name: 'America/New_York', abbrs: ['EST', 'EDT'], untils: [null], offsets: [-300, -240] }, + { name: 'America/Los_Angeles', abbrs: ['PST', 'PDT'], untils: [null], offsets: [-480, -420] }, + { name: 'Europe/London', abbrs: ['GMT', 'BST'], untils: [null], offsets: [0, -60] }, + { name: 'Asia/Tokyo', abbrs: ['JST'], untils: [null], offsets: [-540] }, + ], + links: ['US/Eastern|America/New_York', 'US/Pacific|America/Los_Angeles'], +}; + +describe('TestTimezonesExecutor', () => { + describe('extractTimezoneList', () => { + it('should parse timezone names from TypeScript export', () => { + const result = extractTimezoneList(VALID_TIMEZONE_LIST_CONTENT); + + expect(result).toEqual([ + 'America/New_York', + 'America/Los_Angeles', + 'Europe/London', + 'Asia/Tokyo', + ]); + }); + + it('should ignore commented-out timezone entries', () => { + const contentWithComments = `export default { + value: [ + 'America/New_York', + // Not supported in CI tests + // 'US/Pacific-New', + 'US/Pacific', + ], +}; +`; + const result = extractTimezoneList(contentWithComments); + + expect(result).toEqual(['America/New_York', 'US/Pacific']); + }); + + it('should throw when file does not match expected pattern', () => { + expect(() => extractTimezoneList('const x = 42;')).toThrow('Could not parse timezone list'); + }); + }); + + describe('validateTimezoneList', () => { + it('should return empty array when all timezones are valid', () => { + const bundledTimezones = [ + 'America/New_York', + 'America/Los_Angeles', + 'Europe/London', + 'Asia/Tokyo', + ]; + + const result = validateTimezoneList(bundledTimezones, MOCK_MOMENT_DATA as any); + + expect(result).toEqual([]); + }); + + it('should return invalid timezones not found in moment data', () => { + const bundledTimezones = ['America/New_York', 'Invalid/Timezone', 'Fake/Zone']; + + const result = validateTimezoneList(bundledTimezones, MOCK_MOMENT_DATA as any); + + expect(result).toEqual(['Invalid/Timezone', 'Fake/Zone']); + }); + + it('should recognize timezone link aliases as valid', () => { + const bundledTimezones = ['US/Eastern', 'US/Pacific']; + + const result = validateTimezoneList(bundledTimezones, MOCK_MOMENT_DATA as any); + + expect(result).toEqual([]); + }); + }); + + describe('E2E', () => { + let tempDir: string; + let context: ReturnType; + let projectDir: string; + let errorSpy: jest.SpyInstance; + let mockServer: { close: () => void } | null = null; + + beforeEach(() => { + tempDir = createTempDir('nx-test-timezones-e2e-'); + context = createMockContext({ root: tempDir }); + projectDir = path.join(tempDir, 'packages', 'test-lib'); + fs.mkdirSync(projectDir, { recursive: true }); + errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + errorSpy.mockRestore(); + cleanupTempDir(tempDir); + if (mockServer) { + mockServer.close(); + mockServer = null; + } + }); + + it('should succeed when all bundled timezones exist in fetched data', async () => { + const timezoneFile = path.join(projectDir, 'timezone_list.ts'); + await writeFileText(timezoneFile, VALID_TIMEZONE_LIST_CONTENT); + + // Start a local HTTP server to serve mock data + const http = await import('http'); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(MOCK_MOMENT_DATA)); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address() as { port: number }; + mockServer = server; + + const options: TestTimezonesExecutorSchema = { + timezoneListFile: './timezone_list.ts', + momentTimezoneUrl: `http://127.0.0.1:${address.port}/latest.json`, + }; + + const result = await executor(options, context); + + expect(result.success).toBe(true); + }); + + it('should fail when bundled timezones are not found in fetched data', async () => { + const timezoneFile = path.join(projectDir, 'timezone_list.ts'); + await writeFileText(timezoneFile, INVALID_TIMEZONE_LIST_CONTENT); + + const http = await import('http'); + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(MOCK_MOMENT_DATA)); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address() as { port: number }; + mockServer = server; + + const options: TestTimezonesExecutorSchema = { + timezoneListFile: './timezone_list.ts', + momentTimezoneUrl: `http://127.0.0.1:${address.port}/latest.json`, + }; + + const result = await executor(options, context); + + expect(result.success).toBe(false); + const errorMessage = String(errorSpy.mock.calls[0][0]); + expect(errorMessage).toContain('Invalid/Timezone'); + expect(errorMessage).toContain('Fake/Zone'); + }); + + it('should fail when timezone list file does not exist', async () => { + const options: TestTimezonesExecutorSchema = { + timezoneListFile: './nonexistent.ts', + momentTimezoneUrl: 'http://127.0.0.1:1/unused', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(false); + }); + + it('should fail when remote URL is unreachable', async () => { + const timezoneFile = path.join(projectDir, 'timezone_list.ts'); + await writeFileText(timezoneFile, VALID_TIMEZONE_LIST_CONTENT); + + const options: TestTimezonesExecutorSchema = { + timezoneListFile: './timezone_list.ts', + momentTimezoneUrl: 'http://127.0.0.1:1/nonexistent', + }; + + const result = await executor(options, context); + + expect(result.success).toBe(false); + }); + }); +}); diff --git a/packages/nx-infra-plugin/src/executors/test-timezones/executor.ts b/packages/nx-infra-plugin/src/executors/test-timezones/executor.ts new file mode 100644 index 000000000000..3a4b5ecc02fd --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/test-timezones/executor.ts @@ -0,0 +1 @@ +export { default } from './test-timezones.impl'; diff --git a/packages/nx-infra-plugin/src/executors/test-timezones/schema.json b/packages/nx-infra-plugin/src/executors/test-timezones/schema.json new file mode 100644 index 000000000000..aaacbbd2a0b3 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/test-timezones/schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/schema", + "title": "Test Timezones Executor Schema", + "description": "Fetch the latest moment-timezone data and verify the bundled timezone list contains only valid IANA timezone identifiers", + "type": "object", + "properties": { + "timezoneListFile": { + "type": "string", + "description": "Path to the TypeScript file exporting the timezone list (relative to project root)" + }, + "momentTimezoneUrl": { + "type": "string", + "description": "URL to fetch the latest moment-timezone unpacked data JSON", + "default": "https://raw.githubusercontent.com/moment/moment-timezone/develop/data/unpacked/latest.json" + } + }, + "required": ["timezoneListFile"] +} diff --git a/packages/nx-infra-plugin/src/executors/test-timezones/schema.ts b/packages/nx-infra-plugin/src/executors/test-timezones/schema.ts new file mode 100644 index 000000000000..ce9baeb6fa24 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/test-timezones/schema.ts @@ -0,0 +1,4 @@ +export interface TestTimezonesExecutorSchema { + timezoneListFile: string; + momentTimezoneUrl: string; +} diff --git a/packages/nx-infra-plugin/src/executors/test-timezones/test-timezones.impl.ts b/packages/nx-infra-plugin/src/executors/test-timezones/test-timezones.impl.ts new file mode 100644 index 000000000000..0acf12fcb195 --- /dev/null +++ b/packages/nx-infra-plugin/src/executors/test-timezones/test-timezones.impl.ts @@ -0,0 +1,119 @@ +import * as path from 'path'; +import { logger } from '@nx/devkit'; +import { createExecutor } from '../../utils/create-executor'; +import { readFileText } from '../../utils/file-operations'; +import { TestTimezonesExecutorSchema } from './schema'; + +const DEFAULT_MOMENT_TIMEZONE_URL = + 'https://raw.githubusercontent.com/moment/moment-timezone/develop/data/unpacked/latest.json'; + +interface MomentTimezoneZone { + name: string; + abbrs: string[]; + untils: (number | null)[]; + offsets: number[]; +} + +interface MomentTimezoneData { + zones: MomentTimezoneZone[]; + links: string[]; + version: string; +} + +interface ResolvedTestTimezones { + projectRoot: string; + timezoneListFilePath: string; + momentTimezoneUrl: string; +} + +export function extractTimezoneList(fileContent: string): string[] { + const listMatch = fileContent.match(/value\s*:\s*\[([\s\S]*?)\]/); + if (!listMatch) { + throw new Error('Could not parse timezone list: expected "value: [...]" export pattern'); + } + + const rawList = listMatch[1]; + const timezones = rawList + .split('\n') + .map((line) => line.replace(/\/\/.*$/, '').trim()) // strip inline comments + .join(',') + .split(',') + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) + .filter((entry) => entry.length > 0 && !entry.startsWith('//')); + + return timezones; +} + +export async function fetchMomentTimezoneData(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to fetch moment-timezone data from ${url}: ${response.status} ${response.statusText}`, + ); + } + return response.json() as Promise; +} + +export function validateTimezoneList( + bundledTimezones: string[], + momentData: MomentTimezoneData, +): string[] { + const momentZoneNames = new Set(momentData.zones.map((zone) => zone.name)); + + // Also include link targets (aliases) + for (const link of momentData.links) { + const [, alias] = link.split('|'); + if (alias) { + momentZoneNames.add(alias); + } + // Also add the link source + const [source] = link.split('|'); + if (source) { + momentZoneNames.add(source); + } + } + + const invalidTimezones: string[] = []; + for (const timezone of bundledTimezones) { + if (!momentZoneNames.has(timezone)) { + invalidTimezones.push(timezone); + } + } + + return invalidTimezones; +} + +export default createExecutor({ + name: 'TestTimezones', + resolve: (options, { projectRoot }) => { + const timezoneListFilePath = path.resolve(projectRoot, options.timezoneListFile); + const momentTimezoneUrl = options.momentTimezoneUrl || DEFAULT_MOMENT_TIMEZONE_URL; + return { projectRoot, timezoneListFilePath, momentTimezoneUrl }; + }, + run: async (resolved) => { + logger.verbose('Reading bundled timezone list...'); + const fileContent = await readFileText(resolved.timezoneListFilePath); + const bundledTimezones = extractTimezoneList(fileContent); + logger.verbose(`Found ${bundledTimezones.length} timezones in bundled list`); + + logger.verbose(`Fetching latest moment-timezone data from ${resolved.momentTimezoneUrl}...`); + const momentData = await fetchMomentTimezoneData(resolved.momentTimezoneUrl); + logger.verbose( + `Fetched moment-timezone data (version: ${momentData.version}, ${momentData.zones.length} zones, ${momentData.links.length} links)`, + ); + + const invalidTimezones = validateTimezoneList(bundledTimezones, momentData); + + if (invalidTimezones.length > 0) { + const label = invalidTimezones.length === 1 ? 'timezone' : 'timezones'; + const list = invalidTimezones.map((tz) => ` - ${tz}`).join('\n'); + throw new Error( + `Timezone validation failed: ${invalidTimezones.length} bundled ${label} not found in moment-timezone latest data:\n${list}`, + ); + } + + logger.info( + `All ${bundledTimezones.length} bundled timezones are valid according to moment-timezone data`, + ); + }, +});