@cdmx/wappler_ag_grid 2.1.7 → 2.1.8

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.
@@ -0,0 +1,229 @@
1
+ # AG Grid Wappler · Test Run Findings
2
+
3
+ Latest run: **2026-05-27** · all 14 scenarios PASS against the unmodified
4
+ `dmx-ag-grid.js` v2.1.4 · driven by puppeteer-core against system Chrome
5
+ (`tests/run-all-puppeteer.cjs`). Scenario 13 now performs 11 colour-pixel
6
+ assertions that verify every `rstyles` condition form added in commit
7
+ [42ec4a8](../dmx-ag-grid.js) actually applies the expected number of coloured
8
+ rows.
9
+
10
+ The suite is intentionally tolerant: it verifies that each grid mounts, gets
11
+ data, and renders rows. The state-flag observability bug below means only one
12
+ of the three `onGrid*` flags is visible at a time, so the runner accepts any of
13
+ them as proof the grid mounted.
14
+
15
+ ## Summary
16
+
17
+ | # | Scenario | Status | Grids verified |
18
+ |---|---|---|---|
19
+ | 01 | basic-render | PASS | 1 grid, 25 rows, 10 visible (paged) |
20
+ | 02 | themes-locale | PASS | 9 grids — Alpine/Balham/Material/Quartz/Custom/Dark/HE/RU/ES |
21
+ | 03 | editing | PASS | cell + row + static-select editors |
22
+ | 04 | selection | PASS | multi-row + single-row, checkbox + status events |
23
+ | 05 | action-buttons | PASS | edit/view/delete + button1..5 + condition |
24
+ | 06 | export | PASS | CSV/XLSX/PDF + no-export fallback |
25
+ | 07 | import | PASS | empty grid waiting for file upload (expectEmpty) |
26
+ | 08 | formatting | PASS | amount, date, ctypes, cnames, cwidths, wrap |
27
+ | 09 | grouping | PASS | group_config + columns_to_sum + columns_to_count |
28
+ | 10 | state-persistence | PASS | column state save + filter apply/clear |
29
+ | 11 | layout | PASS | autoHeight, fixed_header offset, horizontal scroll |
30
+ | 12 | methods | PASS | every imperative method exposed as a button |
31
+ | 13 | flags-styles | PASS | suppress_*, hide_filters, **rstyles ×6 forms (asserted)**, cstyles area:text/cell, data_changes |
32
+ | 14 | compact-view | PASS | compact_view + buttons 6–15 + class toggles |
33
+
34
+ Full per-grid state and DOM-row counts are in `tests/findings.json`.
35
+
36
+ ## rstyles coverage for 2.1.4 (commit 42ec4a8)
37
+
38
+ Scenario 13 now exercises the full condition matrix introduced by
39
+ "feat: enhance row color formatting with dynamic conditions" and asserts the
40
+ expected number of coloured rows in the DOM:
41
+
42
+ | Form | Test grid | Sample config | Hand-derived count | Asserted? |
43
+ |---|---|---|---|---|
44
+ | Function — `myFn()` | `rsFn` | `{condition:'isRowVip()'}` (window-scoped) | 3 (rating≥4.7 & active) | ✓ |
45
+ | Value/operator `>` | `rsOp` | `{condition:'salary > 90000'}` | 7 | ✓ |
46
+ | Value/operator `<=` (2.1.4 parser fix) | `rsOp` | `{condition:'salary <= 65000'}` | 5 | ✓ |
47
+ | Compound `&&` | `rsCompound` | `{condition:'active == true && department == Engineering'}` | 7 | ✓ |
48
+ | Field + plain-value shorthand | `rsShort` | `{field:'department', condition:'Marketing'}` | 5 | ✓ |
49
+ | First match wins | `rsFirst` | `[{>95000→orange}, {>90000→yellow}]` | 5 orange / 2 yellow | ✓ |
50
+ | Legacy object form (backwards compat) | `rsLegacy` | `{sales:{…}, hr:{…}}` set programmatically | 5 / 4 | ✓ |
51
+ | cstyles area:text with `>=` | `csCell` | `{field:'salary', condition:'salary >= 90000', area:'text'}` | 7 | ✓ |
52
+ | cstyles area:cell with `<=` | `csCell` | `{field:'salary', condition:'salary <= 65000', area:'cell'}` | 5 | ✓ |
53
+
54
+ ### Real finding from this work
55
+
56
+ **`rstyles` legacy object form is unreachable via `dmx-bind:`.** The 2.1.4 rewrite
57
+ explicitly added a backwards-compat branch:
58
+ ```js
59
+ const styles = Array.isArray(rstyles)
60
+ ? rstyles
61
+ : (rstyles && typeof rstyles === 'object' ? Object.values(rstyles) : []);
62
+ ```
63
+ …but the schema is `rstyles: { type: Array, default: [] }`, and AppConnect's
64
+ type coercion drops every key of an object-shaped binding value, so the runtime
65
+ receives `[]` and the legacy branch is dead code. The branch only fires when
66
+ `rstyles` is set programmatically (e.g. `component.props.rstyles = {…}` then
67
+ `refreshGrid()`), which is what `rsLegacy` does to verify the branch still
68
+ works. Worth either changing the schema to `Object` to make the binding path
69
+ work, or removing the legacy branch.
70
+
71
+ ## Bugs observed in dmx-ag-grid.js
72
+
73
+ The user reverted my proposed patches so the tests run against the production
74
+ file as-is. These are the issues the suite surfaces — listed for the maintainer
75
+ to decide whether to patch.
76
+
77
+ ### 1. `set('state', ...)` overwrites previous state flags
78
+
79
+ **Where:** [dmx-ag-grid.js:1924-1930](../dmx-ag-grid.js#L1924-L1930)
80
+
81
+ ```js
82
+ onGridReady: () => this.set("state", { gridReady: true }),
83
+ onFirstDataRendered:() => this.set("state", { firstDataRendered: true }),
84
+ onRowDataUpdated: () => this.set("state", { rowDataUpdated: true }),
85
+ ```
86
+
87
+ Each call **replaces** the entire `state` object instead of merging. After all
88
+ three fire, only `{ rowDataUpdated: true }` (or whichever fired last) is
89
+ observable in the binding `grid.state.*`. The other two flags are lost.
90
+
91
+ **Visible in the test output:** every passing grid now shows
92
+ `state={"firstDataRendered":true}` — but the 09-grouping `aggGrid` shows
93
+ `state={"rowDataUpdated":true}` because its data refresh fires last. Wappler
94
+ templates that bind `dmx-show="grid.state.gridReady"` will flicker on and
95
+ disappear.
96
+
97
+ **Suggested fix (1 line × 3):**
98
+ ```js
99
+ this.set("state", Object.assign({}, this.data.state, { gridReady: true }));
100
+ // …same pattern for firstDataRendered, rowDataUpdated
101
+ ```
102
+
103
+ ---
104
+
105
+ ### 2. `cwidths[key]` with a non-object value freezes the page
106
+
107
+ **Where:** [dmx-ag-grid.js:1637-1640](../dmx-ag-grid.js#L1637-L1640)
108
+
109
+ ```js
110
+ ...(cwidths.hasOwnProperty(key) && {
111
+ minWidth: parseInt(cwidths[key].min_width), // NaN if cwidths[key] is a number
112
+ maxWidth: parseInt(cwidths[key].max_width),
113
+ }),
114
+ ```
115
+
116
+ The expected shape is `{ field: { min_width: N, max_width: M } }`. A user
117
+ naturally typing `{ first_name: 120 }` (which matches the type declaration
118
+ `Object` and feels intuitive) makes `parseInt(undefined) === NaN`. AG Grid then
119
+ enters an infinite layout loop and **hangs the entire JS thread** — no console
120
+ error, no recovery, just a frozen page.
121
+
122
+ The 08-formatting scenario originally failed this way; the test page now uses
123
+ the documented shape so it passes. But any real user typing a number will hit
124
+ this.
125
+
126
+ **Suggested fix:**
127
+ ```js
128
+ ...(cwidths.hasOwnProperty(key) && cwidths[key] && typeof cwidths[key] === 'object' && {
129
+ ...(Number.isFinite(parseInt(cwidths[key].min_width)) && { minWidth: parseInt(cwidths[key].min_width) }),
130
+ ...(Number.isFinite(parseInt(cwidths[key].max_width)) && { maxWidth: parseInt(cwidths[key].max_width) })
131
+ }),
132
+ ```
133
+
134
+ ---
135
+
136
+ ### 3. `columns_to_count` entries assumed to be objects, crash on strings
137
+
138
+ **Where:** [dmx-ag-grid.js:2007-2009](../dmx-ag-grid.js#L2007-L2009)
139
+
140
+ ```js
141
+ columnsToCount.forEach(function (colObj) {
142
+ const col = colObj.field;
143
+ const uniqueValuesToCount = new Set(colObj.unique_values.split(',')); // crash if colObj is a string
144
+ ```
145
+
146
+ The attribute is declared `type: Array, default: []` which suggests an array of
147
+ column names — but each entry must actually be `{field, unique_values}`. Passing
148
+ `['customer']` throws "Cannot read properties of undefined (reading 'split')".
149
+
150
+ **Suggested fix:** validate + warn on malformed entries.
151
+
152
+ ---
153
+
154
+ ### 4. `action_button_class_toggles` entries crash on bad shape
155
+
156
+ **Where:** [dmx-ag-grid.js:782, 787](../dmx-ag-grid.js#L782)
157
+
158
+ ```js
159
+ if (toggle.btn_id.toLowerCase() === buttonConfig.id && ...) // crash if btn_id missing
160
+ button.classList.add(...toggle.class.split(' ')); // crash if class missing
161
+ ```
162
+
163
+ No type guards on the toggle entries. The expected shape is
164
+ `{btn_id, condition, class}` but mis-shaped entries trigger
165
+ "Cannot read properties of undefined".
166
+
167
+ **Suggested fix:** validate `btn_id` and `class` are strings before using them.
168
+
169
+ ---
170
+
171
+ ### 5. No initial render when bound `data` is already populated at mount time
172
+
173
+ **Where:** [dmx-ag-grid.js:2985-3015](../dmx-ag-grid.js#L2985-L3015) — `requestUpdate`
174
+
175
+ `requestUpdate('data', oldValue)` is the only path that calls `refreshGrid()`.
176
+ If `data` is already at its final value when the component mounts (i.e. the
177
+ binding resolved synchronously before init), no `data` change event fires after
178
+ mount and `refreshGrid()` never runs — the grid stays empty.
179
+
180
+ In production Wappler apps this is masked because `serverConnect` data arrives
181
+ **after** mount. Static fixtures hit it.
182
+
183
+ **Workaround in the test suite:** `<dmx-test-data>` defers its `set('value', …)`
184
+ to `dmx.nextTick` so the grid sees a transition from `[]` → `[…]`. See
185
+ [tests/lib/test-helpers.js:24-31](lib/test-helpers.js#L24-L31).
186
+
187
+ **Suggested upstream fix:** in `init(node)`, if `this.props.data?.length`, call
188
+ `this.refreshGrid()` once. One line, in one place.
189
+
190
+ ## Schema-shape gotchas (worth documenting in components.hjson help text)
191
+
192
+ - `cstatic_select_editors` — `{ field: { options: '<JSON-string>' } }` where
193
+ `options` is a **string** that parses to an object whose keys are the dropdown
194
+ values. Not an array.
195
+ - `cdynamic_select_editors` — `{ field: { options_field: 'someRowField' } }`.
196
+ - `cwidths` — `{ field: { min_width, max_width } }`. Plain numbers freeze the page.
197
+ - `columns_to_count` — array of `{ field, unique_values: csv-string }`. Not array of strings.
198
+ - `action_button_class_toggles` — array of `{ btn_id, condition, class }`. Not `button`/`class_when_true`.
199
+
200
+ ## Test-helper observation (not a module bug)
201
+
202
+ `dmx.app.get(id)` in AppConnect 2.2.4 returns a **reactive-data proxy**, not the
203
+ component instance. Direct field access (`g.state`, `g.count`) works; methods
204
+ are exposed as `__methodName` (`__loadGrid`, `__reloadGrid`); calling `g.get(...)`
205
+ throws "g.get is not a function". `tests/lib/test-helpers.js` documents this
206
+ and exposes a friendlier `TestKit.callMethod('id', 'methodName', ...args)`.
207
+
208
+ ## Test infrastructure
209
+
210
+ - [tests/run-all-puppeteer.cjs](run-all-puppeteer.cjs) — headless Chrome driver
211
+ via `puppeteer-core` against system Chrome. Recommended (~75 s for 14
212
+ scenarios, no daemon hassles).
213
+ - [tests/run-all.cjs](run-all.cjs) — agent-browser CLI driver. Works on Windows
214
+ + Linux but its long-lived daemon can time out after ~7 heavy pages.
215
+ - [tests/serve.cjs](serve.cjs) — static server (port 8765).
216
+
217
+ ## Files touched in this session
218
+
219
+ **Test pages updated to use correct schemas:**
220
+ - `tests/03-editing.html` — `cstatic_select_editors`
221
+ - `tests/08-formatting.html` — `cwidths`
222
+ - `tests/09-grouping.html` — `columns_to_count`
223
+ - `tests/14-compact-view.html` — `action_button_class_toggles`
224
+
225
+ **Test helpers:**
226
+ - `tests/lib/test-helpers.js` — defer `<dmx-test-data>` set, TestKit uses direct field access
227
+ - `tests/lib/page-template.js` — readiness signal accepts any state flag
228
+
229
+ **Module (`dmx-ag-grid.js`):** reverted by user — runs as-shipped.
package/tests/README.md CHANGED
@@ -18,6 +18,10 @@ node tests/serve.cjs # http://localhost:8765/tests/index.html
18
18
  node tests/run-all-puppeteer.cjs
19
19
  # alternative — drives the agent-browser CLI (Windows + Linux):
20
20
  node tests/run-all.cjs
21
+
22
+ # 3. CSP: print the style-src hash allow-list AG Grid + the module need, and
23
+ # (--verify) reload every scenario under that header. Starts its own server.
24
+ node tests/csp-hashes.cjs --verify
21
25
  ```
22
26
 
23
27
  Both produce the same `tests/findings.json` and per-page status table.
@@ -50,9 +54,11 @@ tests/
50
54
  ├── 12-methods.html ← every imperative method as a button
51
55
  ├── 13-flags-styles.html ← suppress_*, rstyles, cstyles, data_changes
52
56
  ├── 14-compact-view.html ← compact_view + buttons 6–15
57
+ ├── 15-csp.html ← nonce-only style-src, no 'unsafe-inline' (__cspCheck)
53
58
  ├── serve.cjs ← cross-platform static server
54
59
  ├── run-all.cjs ← agent-browser CLI driver (cross-platform)
55
60
  ├── run-all-puppeteer.cjs ← headless Chrome driver (recommended)
61
+ ├── csp-hashes.cjs ← CSP style-src hash allow-list generator (+ --verify)
56
62
  ├── data/
57
63
  │ └── sample-data.js ← employees + orders fixtures
58
64
  ├── dmxAppConnect/
@@ -0,0 +1,29 @@
1
+ {
2
+ "$comment": "Versions this test suite was written against. Bump these and re-run the suite when any dependency changes.",
3
+ "module": {
4
+ "name": "@cdmx/wappler_ag_grid",
5
+ "version": "2.1.3"
6
+ },
7
+ "appConnect": {
8
+ "expected": "2.2.4",
9
+ "build": "2026-01-19 14:03:35",
10
+ "file": "./dmxAppConnect/dmxAppConnect.js",
11
+ "detect": "window.dmx && dmx.version"
12
+ },
13
+ "vendor": {
14
+ "ag-grid-community": "~35.1.0",
15
+ "@ag-grid-community/locale": "~35.1.0",
16
+ "papaparse": "~5.5.2",
17
+ "pdfmake": "~0.2.18",
18
+ "read-excel-file": "~5.8.7",
19
+ "exceljs": "^4.4.0"
20
+ },
21
+ "compatibility": {
22
+ "policy": "If dmxAppConnect.js is replaced with a newer build, the suite should keep working as long as the dmx.Component API surface and dmx-bind:* attribute parsing remain backwards-compatible.",
23
+ "smokeCheck": "Open tests/index.html; the AppConnect version pill in the header should match expected. If it drifts, run all scenarios and update this manifest.",
24
+ "knownRisks": [
25
+ "performUpdate vs requestUpdate lifecycle — the grid still uses requestUpdate; AppConnect must keep supporting the older form.",
26
+ "dmx-bind:* attribute parser — any change to expression parsing would break data binding in every scenario."
27
+ ]
28
+ }
29
+ }
@@ -0,0 +1,191 @@
1
+ // Prints the CSP `style-src` hash allow-list that AG Grid (the with-styles UMD bundle) and
2
+ // dmx-ag-grid.js need, so a page can drop 'unsafe-inline' without touching the module.
3
+ //
4
+ // node tests/csp-hashes.cjs → directive + helmet array form on stdout
5
+ // node tests/csp-hashes.cjs --verify → also reload every scenario page under that header;
6
+ // exit 1 on any AG Grid/module violation or broken grid
7
+ //
8
+ // Regenerate after ANY upgrade of ag-grid-community or dmx-ag-grid.js: the hashes are bound to
9
+ // the exact bytes of the injected CSS. Chrome auto-detection is the same as run-all-puppeteer.cjs
10
+ // (set PUPPETEER_EXECUTABLE_PATH to override).
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const http = require('http');
14
+ const crypto = require('crypto');
15
+ const puppeteer = require('puppeteer-core');
16
+
17
+ const ROOT = path.resolve(__dirname, '..');
18
+ const PORT = Number(process.env.AG_TEST_PORT) || 8766;
19
+ const VERIFY = process.argv.includes('--verify');
20
+ // ponytail: coverage = whatever these pages (plus poke() below) make AG Grid inject. A feature
21
+ // outside the suite can need one more hash; add a scenario page for it and re-run.
22
+ const PAGES = fs.readdirSync(__dirname).filter(f => /^\d\d-.*\.html$/.test(f)).sort();
23
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.map': 'application/json' };
24
+ const sha = s => crypto.createHash('sha256').update(s, 'utf8').digest('base64');
25
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
26
+
27
+ function findChrome() {
28
+ if (process.env.PUPPETEER_EXECUTABLE_PATH) return process.env.PUPPETEER_EXECUTABLE_PATH;
29
+ const candidates = process.platform === 'win32' ? [
30
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
31
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
32
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
33
+ 'C:\\Users\\Roney\\.agent-browser\\browsers\\chrome-148.0.7778.97\\chrome.exe'
34
+ ] : process.platform === 'darwin' ? [
35
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
36
+ '/Applications/Chromium.app/Contents/MacOS/Chromium'
37
+ ] : [
38
+ '/usr/bin/google-chrome',
39
+ '/usr/bin/chromium',
40
+ '/usr/bin/chromium-browser',
41
+ '/snap/bin/chromium'
42
+ ];
43
+ return candidates.find(p => fs.existsSync(p));
44
+ }
45
+
46
+ // Static server over the repo root (serve.cjs cannot set headers). `csp` is sent on HTML responses.
47
+ let csp = null;
48
+ const server = http.createServer((req, res) => {
49
+ const file = path.normalize(path.join(ROOT, decodeURIComponent(req.url.split('?')[0])));
50
+ if (!file.startsWith(ROOT)) { res.writeHead(403); return res.end(); }
51
+ fs.readFile(file, (err, buf) => {
52
+ if (err) { res.writeHead(404); return res.end('404'); }
53
+ const headers = { 'content-type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' };
54
+ if (csp && file.endsWith('.html')) headers['content-security-policy'] = csp;
55
+ res.writeHead(200, headers);
56
+ res.end(buf);
57
+ });
58
+ });
59
+
60
+ // <style> blocks written into the scenario pages themselves are the pages' business, not the list's.
61
+ const pageOwned = new Set();
62
+ for (const f of PAGES) {
63
+ for (const m of fs.readFileSync(path.join(__dirname, f), 'utf8').matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
64
+ pageOwned.add(sha(m[1]));
65
+ pageOwned.add(sha(m[1].replace(/\r\n/g, '\n')));
66
+ }
67
+ }
68
+
69
+ // The module's three static blocks, hashed from source (template literals normalise CRLF to LF),
70
+ // so they are listed even when no scenario switches the option on.
71
+ function moduleBlocks() {
72
+ const src = fs.readFileSync(path.join(ROOT, 'dmx-ag-grid.js'), 'utf8');
73
+ const wanted = [
74
+ ['dmx-ag-grid.js cell centering', /styleElement\.textContent \+= `([\s\S]*?)`;/],
75
+ ['dmx-ag-grid.js sticky h-scroll', /styleElement\.innerHTML = `([\s\S]*?)`;/],
76
+ ['dmx-ag-grid.js pagination panel', /const paginationPanelCss = `([\s\S]*?)`;/]
77
+ ];
78
+ const out = [];
79
+ for (const [tag, re] of wanted) {
80
+ const m = src.match(re);
81
+ if (m) out.push({ hash: sha(m[1].replace(/\r\n/g, '\n')), tag, len: m[1].length });
82
+ else console.warn('warning: "' + tag + '" block not found in dmx-ag-grid.js — the module changed; check the runtime <style> blocks');
83
+ }
84
+ return out;
85
+ }
86
+
87
+ // Best-effort clicks so AG Grid also injects the CSS of lazily created parts (menus, popups, editors).
88
+ async function poke(page) {
89
+ const steps = [
90
+ async () => { await (await page.$('.ag-header-cell')).hover(); await sleep(150); await (await page.$('.ag-header-cell-menu-button')).click(); },
91
+ async () => { await (await page.$('.ag-floating-filter-button-button')).click(); },
92
+ async () => { await (await page.$('.ag-cell')).click({ clickCount: 2 }); },
93
+ async () => { await (await page.$('.ag-header-cell-label')).click(); },
94
+ async () => { await (await page.$('.ag-paging-button[data-ref="btNext"]')).click(); }
95
+ ];
96
+ for (const step of steps) {
97
+ try { await step(); await sleep(300); await page.keyboard.press('Escape'); } catch (_) { /* element absent on this page */ }
98
+ }
99
+ }
100
+
101
+ async function load(browser, file, policy) {
102
+ csp = policy;
103
+ const page = await browser.newPage();
104
+ page.setDefaultTimeout(30000);
105
+ const demanded = new Set(); // hashes Chrome names in its CSP console errors
106
+ page.on('console', m => {
107
+ const t = m.text();
108
+ if (/Content Security Policy/.test(t)) (t.match(/sha256-[A-Za-z0-9+/=]+/g) || []).forEach(h => demanded.add(h.slice(7)));
109
+ });
110
+ await page.evaluateOnNewDocument(() => {
111
+ window.__cspv = [];
112
+ window.__styles = new Map(); // text → origin
113
+ document.addEventListener('securitypolicyviolation', e => window.__cspv.push({ directive: e.violatedDirective, src: (e.sourceFile || '').split('/').pop() }));
114
+ const rec = el => {
115
+ if (!el || el.nodeType !== 1 || el.tagName !== 'STYLE' || window.__styles.has(el.textContent)) return;
116
+ window.__styles.set(el.textContent, el.getAttribute('data-ag-scope') ? 'ag-grid bundle (style-loader)' : el.dataset.agGlobalCss ? 'ag-grid theming api' : 'other');
117
+ };
118
+ new MutationObserver(ms => {
119
+ for (const m of ms) {
120
+ if (m.type === 'characterData') { rec(m.target.parentElement); continue; }
121
+ rec(m.target);
122
+ m.addedNodes.forEach(n => {
123
+ if (n.nodeType === 1) { rec(n); n.querySelectorAll && n.querySelectorAll('style').forEach(rec); }
124
+ else if (n.nodeType === 3) rec(n.parentElement);
125
+ });
126
+ }
127
+ }).observe(document, { childList: true, subtree: true, characterData: true });
128
+ });
129
+ await page.goto('http://127.0.0.1:' + PORT + '/tests/' + file, { waitUntil: 'domcontentloaded' });
130
+ try { await page.waitForFunction(() => document.body.dataset.gridsReady === 'true', { timeout: 8000 }); } catch (_) { /* empty-grid pages never set it */ }
131
+ await sleep(500);
132
+ await poke(page);
133
+ await sleep(400);
134
+ const result = await page.evaluate(() => {
135
+ document.querySelectorAll('style').forEach(s => { if (!window.__styles.has(s.textContent)) window.__styles.set(s.textContent, s.dataset.agGlobalCss ? 'ag-grid theming api' : 'other'); });
136
+ const grids = {};
137
+ document.querySelectorAll('dmx-ag-grid').forEach(g => { const w = document.querySelector('#' + g.id + '-grid .ag-root-wrapper'); grids[g.id] = w ? getComputedStyle(w).display : 'n/a'; });
138
+ return { styles: [...window.__styles.entries()], violations: window.__cspv, grids };
139
+ });
140
+ await page.close();
141
+ return { ...result, demanded: [...demanded] };
142
+ }
143
+
144
+ (async () => {
145
+ const exe = findChrome();
146
+ if (!exe) { console.error('No Chrome executable found — set PUPPETEER_EXECUTABLE_PATH'); process.exit(2); }
147
+ await new Promise(r => server.listen(PORT, '127.0.0.1', r));
148
+ const browser = await puppeteer.launch({ executablePath: exe, headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] });
149
+
150
+ // 1. Harvest every <style> text the pages ever hold.
151
+ const blocks = new Map(); // hash → { tag, len }
152
+ for (const b of moduleBlocks()) blocks.set(b.hash, { tag: b.tag, len: b.len });
153
+ blocks.set(sha(''), { tag: 'empty <style> (style-loader inserts it empty, then fills it)', len: 0 });
154
+ for (const f of PAGES) {
155
+ for (const [text, tag] of (await load(browser, f, null)).styles) {
156
+ const h = sha(text);
157
+ if (!blocks.has(h) && !pageOwned.has(h)) blocks.set(h, { tag, len: text.length });
158
+ }
159
+ }
160
+ for (const [h, b] of blocks) if (b.tag === 'other') console.warn('note: allowing an unattributed <style> block of ' + b.len + ' chars: ' + h);
161
+
162
+ const rank = t => /^dmx-ag-grid/.test(t) ? 0 : /^empty/.test(t) ? 1 : /bundle/.test(t) ? 2 : /theming/.test(t) ? 3 : 4;
163
+ const ordered = [...blocks.entries()].sort((a, b) => rank(a[1].tag) - rank(b[1].tag) || a[0].localeCompare(b[0]));
164
+ const sources = ordered.map(([h]) => "'sha256-" + h + "'");
165
+ const directive = "style-src 'self' " + sources.join(' ');
166
+ const groups = ['dmx-ag-grid.js', 'empty', 'ag-grid bundle', 'ag-grid theming api', 'other'];
167
+ const counts = {};
168
+ for (const [, b] of ordered) counts[groups[rank(b.tag)]] = (counts[groups[rank(b.tag)]] || 0) + 1;
169
+ console.log('# ' + blocks.size + ' hashes ' + JSON.stringify(counts) + ' — also needed: img-src \'self\' data:; font-src \'self\' data:');
170
+ console.log(directive + '\n');
171
+ console.log('# helmet: styleSrc: ' + JSON.stringify(["'self'", ...sources]) + '\n');
172
+
173
+ // 2. Verify: every page under the header, no AG Grid/module violation, every grid laid out.
174
+ let failed = 0;
175
+ if (VERIFY) {
176
+ const header = directive + "; font-src 'self' data:; img-src 'self' data:";
177
+ for (const f of PAGES) {
178
+ const r = await load(browser, f, header);
179
+ const ours = r.violations.filter(v => !/^\d\d-.*\.html$/.test(v.src)); // the pages' own style= / <style> are excluded
180
+ const broken = Object.entries(r.grids).filter(([, d]) => d !== 'flex' && d !== 'n/a').map(([id]) => id);
181
+ const ok = !ours.length && !broken.length;
182
+ if (!ok) failed++;
183
+ console.log((ok ? 'PASS ' : 'FAIL ') + f.padEnd(28) + ' ag-grid/module violations=' + ours.length + ' broken grids=' + JSON.stringify(broken));
184
+ if (ours.length) console.log(' hashes Chrome asked for: ' + r.demanded.join(', '));
185
+ }
186
+ console.log(failed ? '\n' + failed + ' page(s) failed under the hash-only policy' : '\nall ' + PAGES.length + ' pages pass under the hash-only policy');
187
+ }
188
+ await browser.close();
189
+ server.close();
190
+ process.exit(failed ? 1 : 0);
191
+ })().catch(e => { console.error(e); process.exit(1); });