fix(cms): capture array container before node removal so reindex runs
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions

The  delete buttons called wrap.closest('.array-items') AFTER remove();
a detached node has no ancestors, so closest() returned null and
reindexItems() silently skipped. Remaining items kept their old indices,
collect() produced sparse arrays (null holes) and saves failed schema
validation, e.g. '$.details.services[1].specs.items[0]: string érték
szükséges'. Capture the container before remove() for both str-item and
obj-card delete handlers.

Regression test runs the real browser script in jsdom and clicks the
actual delete buttons (nested string array + object card reindexing).

Closes MITHOME-67
This commit is contained in:
Do Siki
2026-08-19 13:30:53 +02:00
parent 19f2fdfece
commit 5d2141ec88
2 changed files with 105 additions and 2 deletions
@@ -0,0 +1,91 @@
/**
* Regression test for the Content Editor browser script: deleting an array
* item via its ❌ button must reindex the remaining items, otherwise collect()
* produces sparse arrays (null holes) that fail schema validation
* ("$.details.services[1].specs.items[0]: string érték szükséges").
*
* Runs the REAL scripts/cms-editor-client.js in jsdom and clicks the actual
* delete buttons — earlier coverage only exercised reindexItems() directly,
* which missed that the onclick handler removed the node BEFORE looking up
* its container (detached node → closest() === null → no reindex).
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
const service = (n: number) => ({
id: `svc-${n}`,
title: `Szolgáltatás ${n}`,
description: `Leírás ${n}`,
icon: '🔧',
features: [`feature ${n}`],
ctaText: 'CTA',
})
const makeData = () => ({
details: {
title: 'Részletek',
subtitle: 'Alcím',
services: [
{ icon: 'a', title: 's0', description: 'd0', specs: { title: 't0', items: ['a0', 'b0', 'c0'] } },
{ icon: 'b', title: 's1', description: 'd1', specs: { title: 't1', items: ['a1', 'b1', 'c1'] } },
{ icon: 'c', title: 's2', description: 'd2', specs: { title: 't2', items: ['a2', 'b2', 'c2'] } },
],
},
})
function bootClient(data: unknown) {
;(global as any).DATA = data
;(global as any).FILE = 'services'
;(global as any).CSRF_TOKEN = 'test-token'
;(global as any).fetch = jest.fn()
document.body.innerHTML = '<div id="editor"></div>'
// sloppy-mode eval publishes the script's functions on the global object
;(0, eval)(clientJs)
}
function deleteButtonFor(dataPath: string): HTMLButtonElement {
const field = document.querySelector(`[data-path="${CSS.escape(dataPath)}"]`) as HTMLElement
expect(field).not.toBeNull()
const wrap = field.closest('.str-item') as HTMLElement
expect(wrap).not.toBeNull()
return wrap.querySelector('.btn-del') as HTMLButtonElement
}
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
})
describe('Content Editor client delete/reindex', () => {
it('deleting a nested string array item keeps the remaining items dense', () => {
const data = makeData()
bootClient(data)
deleteButtonFor('details.services[1].specs.items[0]').click()
const collected = (global as any).collect()
expect(collected.details.services[1].specs.items).toEqual(['b1', 'c1'])
expect(collected.details.services[0].specs.items).toEqual(['a0', 'b0', 'c0'])
expect(collected.details.services[2].specs.items).toEqual(['a2', 'b2', 'c2'])
})
it('deleting an object card reindexes the outer array', () => {
const data = makeData()
bootClient(data)
const cardHeader = Array.from(document.querySelectorAll('.card-header'))
.find(h => h.textContent === 'details.services[1]') as HTMLElement
expect(cardHeader).not.toBeNull()
const card = cardHeader.closest('.obj-card') as HTMLElement
;(card.querySelector('.btn-del-card') as HTMLButtonElement).click()
const collected = (global as any).collect()
expect(collected.details.services).toHaveLength(2)
expect(collected.details.services[0].title).toBe('s0')
expect(collected.details.services[1].title).toBe('s2')
expect(collected.details.services[1].specs.items).toEqual(['a2', 'b2', 'c2'])
})
})
+14 -2
View File
@@ -116,7 +116,14 @@ function makeStrItem(val, idx, path) {
del.className = 'btn-del';
del.textContent = '❌';
del.title = 'Törlés';
del.onclick = () => { wrap.remove(); reindexItems(wrap.closest('.array-items')); };
del.onclick = () => {
// WHY: capture the container BEFORE removing — a detached node has no
// ancestors, so closest() would return null and reindexing would silently
// not run (sparse arrays → schema errors on save).
const container = wrap.closest('.array-items');
wrap.remove();
reindexItems(container);
};
wrap.appendChild(ta);
wrap.appendChild(del);
return wrap;
@@ -135,7 +142,12 @@ function makeObjCard(obj, idx, path) {
const del = document.createElement('button');
del.className = 'btn-del-card';
del.textContent = '❌ Törlés';
del.onclick = () => { card.remove(); reindexItems(card.closest('.array-items')); };
del.onclick = () => {
// Same as above: capture before detaching, or reindexing is skipped.
const container = card.closest('.array-items');
card.remove();
reindexItems(container);
};
card.appendChild(del);
return card;
}