@elliemae/ds-monorepo-devops 3.70.0-next.6 → 3.70.0-next.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import normalizePath from 'normalize-path';
5
5
  import { swcrcConfig } from './swcrc.config.cjs';
6
6
  import { findMonoRepoRoot } from './utils.cjs';
7
+ import { resolvePinnedStyledComponents } from '../../resolve-pinned-styled-components.cjs';
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
9
10
  const __dirname = path.dirname(__filename); // get the name of the directory
@@ -19,6 +20,14 @@ const getNodeModulesPath = (fileName) => {
19
20
  );
20
21
  };
21
22
 
23
+ // See configs/resolve-pinned-styled-components.cjs for the full rationale. In short:
24
+ // styled-components@5.3.11 resolves to two pnpm instances (pnpm/pnpm#11834), which means two
25
+ // ThemeContext objects, which means themeProviderHOC's ThemeProvider is invisible to styled()
26
+ // calls resolved through the other instance. Both packages must be aliased onto one instance.
27
+ // This intentionally throws rather than degrading: a missing pin surfaces as dozens of
28
+ // "Cannot read properties of undefined" render errors, a long way from the cause.
29
+ const pinnedStyledComponentsPaths = resolvePinnedStyledComponents(process.cwd());
30
+
22
31
  const configuredJestConfig = {
23
32
  coverageThreshold: {},
24
33
  coverageProvider: 'v8',
@@ -39,6 +48,9 @@ const configuredJestConfig = {
39
48
  '@elliemae/pui-diagnostics': getMockFilePath('pui-diagnostics.js'),
40
49
  'react-spring/web': getNodeModulesPath('react-spring/web.cjs.js'),
41
50
  'react-spring/renderprops': getNodeModulesPath('react-spring/renderprops.cjs.js'),
51
+ // See resolve-pinned-styled-components.cjs for why these two are needed.
52
+ '^styled-components$': normalizePath(pinnedStyledComponentsPaths.styledComponentsPath),
53
+ '^@xstyled/styled-components$': normalizePath(pinnedStyledComponentsPaths.xstyledStyledComponentsPath),
42
54
  },
43
55
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
44
56
  setupFilesAfterEnv: [path.resolve(__dirname, './setup-tests.js'), path.resolve(__dirname, './setup-react-env.js')],
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ const localRequire = createRequire(import.meta.url);
6
+
7
+ /**
8
+ * The Playwright version pre-installed in the Jenkins CI image. This is the *image's* value, not a
9
+ * second copy of ours — the catalog pins in `pnpm-workspace.yaml` say what this workspace uses, and
10
+ * this says what CI can actually run. The check below exists to prove the two agree.
11
+ *
12
+ * Jenkins runs component tests inside `docker-local/nodejs22pnpm10` (built from
13
+ * `pui-nodejs-docker@22.x-pnpm10`). That image copies browser binaries out of a pinned
14
+ * `playwright:v<version>-noble` image and sets `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`, because the DC4
15
+ * proxy blocks `cdn.playwright.dev`. CI therefore cannot fetch a browser it was not built with, and
16
+ * Playwright resolves browsers by revision-stamped directory (`chromium-<revision>`) with one exact
17
+ * revision per release — so a version difference of any size, including a patch, is a hard failure.
18
+ *
19
+ * Changing the Playwright version is a coordinated, ordered change:
20
+ * 1. bump `ARG PLAYWRIGHT_VERSION` in `pui-nodejs-docker@22.x-pnpm10`
21
+ * 2. merge it and let Jenkins publish the image
22
+ * 3. bump the catalog pins and this constant together, in one commit
23
+ *
24
+ * Step 1 is also the availability constraint: the Artifactory docker mirror for the Playwright base
25
+ * image is curated rather than pull-through, so only tags already mirrored there can be built
26
+ * against, which can lag npm by several releases.
27
+ */
28
+ export const CI_IMAGE_PLAYWRIGHT_VERSION = '1.61.1';
29
+
30
+ export const CI_IMAGE_NAME = 'docker-local/nodejs22pnpm10';
31
+ export const CI_IMAGE_SOURCE = 'pui-nodejs-docker@22.x-pnpm10';
32
+
33
+ /** Set to any truthy value to downgrade the mismatch to a warning. Ignored under CI, where a
34
+ * mismatch is not a judgement call — the browsers genuinely are not in the image. */
35
+ export const OVERRIDE_ENV_VAR = 'DS_PLAYWRIGHT_ALLOW_CI_IMAGE_MISMATCH';
36
+
37
+ const readResolvedPlaywrightVersion = () => localRequire('@playwright/experimental-ct-react/package.json').version;
38
+
39
+ // browsers.json is not in playwright-core's exports map, so resolve the package and read alongside.
40
+ const readResolvedChromiumRevision = () => {
41
+ try {
42
+ const coreDir = path.dirname(localRequire.resolve('playwright-core/package.json'));
43
+ const { browsers } = JSON.parse(fs.readFileSync(path.join(coreDir, 'browsers.json'), 'utf8'));
44
+ return browsers.find(({ name }) => name === 'chromium')?.revision ?? null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ const buildMismatchMessage = (resolvedVersion) => {
51
+ const revision = readResolvedChromiumRevision();
52
+ const resolvedDetail = revision ? `${resolvedVersion} (chromium-${revision})` : resolvedVersion;
53
+ return [
54
+ 'Playwright is out of contract with the CI image.',
55
+ '',
56
+ ` this workspace resolves ${resolvedDetail}`,
57
+ ` ${CI_IMAGE_NAME} pre-installs ${CI_IMAGE_PLAYWRIGHT_VERSION}`,
58
+ '',
59
+ 'The Playwright version must match the CI image exactly or Jenkins will fail. Component tests',
60
+ 'run inside that image with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 and cannot download browsers, so',
61
+ 'this passes locally and fails in CI. Patch differences count: each release expects one exact',
62
+ 'browser revision.',
63
+ '',
64
+ 'Changing the Playwright version requires coordination with the docker image, in this order:',
65
+ ` 1. bump ARG PLAYWRIGHT_VERSION in ${CI_IMAGE_SOURCE}`,
66
+ ' 2. merge it and let Jenkins publish the image',
67
+ ' 3. bump the catalog pins in pnpm-workspace.yaml and CI_IMAGE_PLAYWRIGHT_VERSION together',
68
+ '',
69
+ 'Note that the Artifactory docker mirror for the Playwright base image is curated, not',
70
+ 'pull-through, so the newest version on npm may not be buildable yet.',
71
+ '',
72
+ `To proceed anyway on this machine: ${OVERRIDE_ENV_VAR}=1 (has no effect under CI).`,
73
+ ].join('\n');
74
+ };
75
+
76
+ /**
77
+ * Throws when the resolved Playwright version differs from the one the CI image ships.
78
+ *
79
+ * Deliberately unconditional rather than gated on "are the browsers missing?". The Playwright
80
+ * browser cache is machine-global and accumulates revisions forever, so a developer who bumps the
81
+ * version usually already has the new revision cached — an install-triggered check would stay
82
+ * silent for exactly the person about to break CI.
83
+ */
84
+ export function assertPlaywrightMatchesCiImage() {
85
+ const resolvedVersion = readResolvedPlaywrightVersion();
86
+ if (resolvedVersion === CI_IMAGE_PLAYWRIGHT_VERSION) return;
87
+
88
+ const message = buildMismatchMessage(resolvedVersion);
89
+ if (process.env[OVERRIDE_ENV_VAR] && !process.env.CI) {
90
+ console.warn(message);
91
+ return;
92
+ }
93
+ const error = new Error(message);
94
+ // Playwright prints the stack of a config-load failure verbatim. The frames are noise here — the
95
+ // fault is a version pin, not a code path — and they push the instructions off a short terminal.
96
+ error.stack = message;
97
+ throw error;
98
+ }
@@ -1,10 +1,35 @@
1
1
  import { defineConfig, devices } from '@playwright/experimental-ct-react';
2
- // import { defineConfig as viteDefineConfig } from 'vite';
3
- // import react from '@vitejs/plugin-react';
2
+ import { findMonoRepoRoot } from '../jest/testing/utils.cjs';
3
+ import { assertPlaywrightMatchesCiImage } from './ci-image-contract.mjs';
4
+
5
+ // Every component-testing package re-exports this config, so this is the one place every `playwright
6
+ // test` invocation passes through. Skipped when SKIP_PLAYWRIGHT=1 disables testMatch below: no tests
7
+ // run, so no browser is needed and the contract does not apply.
8
+ if (process.env.SKIP_PLAYWRIGHT !== '1') assertPlaywrightMatchesCiImage();
9
+ import { resolvePinnedStyledComponents } from '../resolve-pinned-styled-components.cjs';
10
+
11
+ // Shared with the Jest base config and .storybook/main.jsx — see
12
+ // configs/resolve-pinned-styled-components.cjs for the full rationale. Short version:
13
+ // styled-components@5.3.11 resolves to two pnpm store instances (pnpm/pnpm#11834), which means
14
+ // two ThemeContext objects, which means ThemeProvider from one instance is invisible to styled()
15
+ // calls resolved through the other. This is the Playwright component-testing runtime's own
16
+ // internal Vite instance, which neither the Jest moduleNameMapper nor the Storybook/Portal/
17
+ // react-18-vite resolve.alias entries cover — it needs its own alias.
18
+ //
19
+ // NOTE: Playwright CT caches its built bundle in `playwright/.cache`, and changing this alias
20
+ // does NOT invalidate that cache. Clear it (`pnpm run clean`, or rimraf **/playwright/.cache)
21
+ // or a change here will silently do nothing.
22
+ const resolvePinnedStyledComponentsAliases = () => {
23
+ const { styledComponentsPath, xstyledStyledComponentsPath } = resolvePinnedStyledComponents(process.cwd());
24
+ return {
25
+ 'styled-components': styledComponentsPath,
26
+ '@xstyled/styled-components': xstyledStyledComponentsPath,
27
+ };
28
+ };
4
29
 
5
30
  export const config = defineConfig({
6
31
  testDir: './src/tests',
7
- testMatch: '**/*.test.playwright.@(js|jsx|ts|tsx)',
32
+ testMatch: process.env.SKIP_PLAYWRIGHT === '1' ? [] : '**/*.test.playwright.@(js|jsx|ts|tsx)',
8
33
  snapshotDir: './__snapshots__',
9
34
  timeout: 10 * 1000,
10
35
  fullyParallel: process.env.CI ? false : true,
@@ -15,13 +40,21 @@ export const config = defineConfig({
15
40
  workers: process.env.CI ? 1 : undefined,
16
41
  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
17
42
  reporter: 'list',
18
- // ctViteConfig: viteDefineConfig({ logLevel: 'info', plugins: [react()] }),
19
43
  use: {
20
44
  ctPort: 31500,
21
45
  headless: process.env.CI || process.env.PW_HEADLESS ? true : false,
22
46
  launchOptions: {
23
47
  devtools: process.env.CI || process.env.PW_HEADLESS ? false : true,
24
48
  },
49
+ // Note: ctViteConfig deep-merges with Playwright's own base/framework Vite config (see
50
+ // experimental-ct-core/lib/viteUtils.js — mergeConfig, then frameworkOverrides applied after).
51
+ // Do not add a `plugins` entry here — the React plugin is injected separately via
52
+ // frameworkOverrides regardless of what's passed here, so re-adding it would register it twice.
53
+ ctViteConfig: {
54
+ resolve: {
55
+ alias: resolvePinnedStyledComponentsAliases(),
56
+ },
57
+ },
25
58
  },
26
59
  projects: [
27
60
  {
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Single source of truth for the styled-components / @xstyled/styled-components pinning.
3
+ *
4
+ * WHY THIS EXISTS
5
+ * styled-components@5.3.11 currently resolves to two different pnpm store instances (one paired
6
+ * with react-is@18.3.1, one with react-is@19.2.8 — see pnpm/pnpm#11834:
7
+ * dedupePeerDependents fails to collapse peer-cyclic packages like
8
+ * styled-components <-> babel-plugin-styled-components). @xstyled/styled-components (used by
9
+ * ds-system's styled() wrapper and by themeProviderHOC for theme merging) inherits that split
10
+ * per-consumer. Two module instances means two separate Context objects, so ThemeProvider from
11
+ * one instance is invisible to styled() calls resolved through the other — which is what
12
+ * actually breaks theme merging (not react-is itself). Every consumer (Jest, Storybook/Vite,
13
+ * Playwright CT, the portal, react-18-vite) must alias both packages onto the single instance
14
+ * built against react-is@18.3.1.
15
+ *
16
+ * WHY IT IS SHARED
17
+ * This logic previously existed as five hand-copied duplicates. They drifted in lockstep and all
18
+ * five broke on Windows in the same way (see below), which is exactly the failure mode a shared
19
+ * module prevents.
20
+ *
21
+ * WHY IT MATCHES ON REALPATH, NOT ON DIRECTORY NAMES
22
+ * The previous implementation matched on the *name* of the pnpm virtual-store directory:
23
+ * `e.includes('_react-is@18.3.1_')` and `e.startsWith('@xstyled+styled-components@3.8.1')`.
24
+ * pnpm defaults `virtual-store-dir-max-length` to 120 on Linux/macOS but **60 on Windows**
25
+ * (to stay under MAX_PATH). At 60 characters the peer suffix is truncated away and replaced by
26
+ * a hash — `styled-components@5.3.11_@b_0a4e3f58…`, `@xstyled+styled-components@_7aca0cfd…` —
27
+ * so both predicates matched zero entries, the resolver returned null, the aliases were
28
+ * silently dropped, and every themed component rendered with `theme = {}`.
29
+ * Resolving through `fs.realpathSync` instead is immune to how pnpm names its directories.
30
+ *
31
+ * WHY IT THROWS
32
+ * Returning null on failure turns a broken workaround into a silent behaviour change: the
33
+ * symptom is dozens of confusing "Cannot read properties of undefined (reading 'small')"
34
+ * render errors, a long way from the cause. Failing loudly here is the whole point.
35
+ */
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const { findMonoRepoRoot } = require('./jest/testing/utils.cjs');
39
+
40
+ const STYLED_COMPONENTS_PREFIX = 'styled-components@5.3.11';
41
+ const XSTYLED_PREFIX = '@xstyled+styled-components@';
42
+ const PINNED_REACT_IS = 'react-is@18.3.1';
43
+
44
+ /** `fs.realpathSync` that returns null instead of throwing on a missing path. */
45
+ const realpathOrNull = (target) => {
46
+ try {
47
+ return fs.realpathSync(target);
48
+ } catch (_) {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /**
54
+ * Resolve the single styled-components / @xstyled-styled-components pair that both build
55
+ * against react-is@18.3.1.
56
+ *
57
+ * @param {string} [cwd] directory to start the monorepo-root search from
58
+ * @returns {{styledComponentsPath: string, xstyledStyledComponentsPath: string}}
59
+ * @throws if the pair cannot be resolved — never returns a partial or empty result
60
+ */
61
+ const resolvePinnedStyledComponents = (cwd = process.cwd()) => {
62
+ const monorepoRoot = findMonoRepoRoot(cwd);
63
+ if (!monorepoRoot) throw new Error(`Could not locate the monorepo root from "${cwd}"`);
64
+
65
+ const pnpmDir = path.join(monorepoRoot, 'node_modules', '.pnpm');
66
+ if (!fs.existsSync(pnpmDir)) throw new Error(`pnpm virtual store not found at "${pnpmDir}" — run \`pnpm i\` first`);
67
+
68
+ const entries = fs.readdirSync(pnpmDir);
69
+
70
+ // Match on what the directory *is* (which react-is it links to), not on what it is *named* —
71
+ // pnpm truncates these names to 60 characters on Windows.
72
+ const scEntry = entries.find((entry) => {
73
+ if (!entry.startsWith(STYLED_COMPONENTS_PREFIX)) return false;
74
+ const reactIs = realpathOrNull(path.join(pnpmDir, entry, 'node_modules', 'react-is'));
75
+ return Boolean(reactIs) && reactIs.includes(PINNED_REACT_IS);
76
+ });
77
+ if (!scEntry) {
78
+ throw new Error(
79
+ `Could not find a ${STYLED_COMPONENTS_PREFIX} instance built against ${PINNED_REACT_IS} in "${pnpmDir}".\n` +
80
+ ` candidates: ${entries.filter((e) => e.startsWith(STYLED_COMPONENTS_PREFIX)).join(', ') || '(none)'}\n` +
81
+ ` Without this pin, ThemeProvider and styled() resolve to different module instances ` +
82
+ `and every themed component renders with an empty theme.`,
83
+ );
84
+ }
85
+
86
+ const styledComponentsPath = path.join(pnpmDir, scEntry, 'node_modules', 'styled-components');
87
+ const scRealPath = fs.realpathSync(styledComponentsPath);
88
+
89
+ const xscEntry = entries
90
+ .filter((entry) => entry.startsWith(XSTYLED_PREFIX))
91
+ .find((entry) => realpathOrNull(path.join(pnpmDir, entry, 'node_modules', 'styled-components')) === scRealPath);
92
+ if (!xscEntry) {
93
+ throw new Error(
94
+ `Found ${scEntry} but no ${XSTYLED_PREFIX}* instance bundling the same styled-components copy.\n` +
95
+ ` looking for a nested styled-components resolving to: ${scRealPath}\n` +
96
+ ` candidates: ${entries.filter((e) => e.startsWith(XSTYLED_PREFIX)).join(', ') || '(none)'}`,
97
+ );
98
+ }
99
+
100
+ return {
101
+ styledComponentsPath,
102
+ xstyledStyledComponentsPath: path.join(pnpmDir, xscEntry, 'node_modules', '@xstyled', 'styled-components'),
103
+ };
104
+ };
105
+
106
+ module.exports = { resolvePinnedStyledComponents };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliemae/ds-monorepo-devops",
3
- "version": "3.70.0-next.6",
3
+ "version": "3.70.0-next.60",
4
4
  "license": "MIT",
5
5
  "description": "ICE MT - Dimsum - Monorepo Devops",
6
6
  "type": "module",
@@ -24,6 +24,14 @@
24
24
  "./configs/playwright/playwright-component-testing-config": {
25
25
  "import": "./configs/playwright/playwright-component-testing-config.mjs",
26
26
  "require": "./configs/playwright/playwright-component-testing-config.mjs"
27
+ },
28
+ "./configs/playwright/ci-image-contract": {
29
+ "import": "./configs/playwright/ci-image-contract.mjs",
30
+ "require": "./configs/playwright/ci-image-contract.mjs"
31
+ },
32
+ "./configs/resolve-pinned-styled-components": {
33
+ "import": "./configs/resolve-pinned-styled-components.cjs",
34
+ "require": "./configs/resolve-pinned-styled-components.cjs"
27
35
  }
28
36
  },
29
37
  "sideEffects": [],
@@ -93,7 +101,7 @@
93
101
  "typeSafety": false
94
102
  },
95
103
  "dependencies": {
96
- "@playwright/experimental-ct-react": "^1.51.1"
104
+ "@playwright/experimental-ct-react": "1.61.1"
97
105
  },
98
106
  "scripts": {}
99
- }
107
+ }