fix(cms): stable bottom bar and self-save no longer trips the 409 lock
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
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
MITHOME-68: the optimistic-lock fingerprint was frozen at page load, so the user's own second save 409'd. /save now returns the hash of the written content and the client refreshes CONTENT_HASH on success — 409 only fires for genuine external changes (deploy, other tab, restore). Also: successful logins no longer consume the auth failure budget (only failed attempts do). MITHOME-69: bottom bar items no longer shift while saving/publishing — the status message occupies a constant flex slot (visibility instead of display), the publish button locks its width while running and restores its env-specific label, auto margins removed. Layout guard test added. Test markers are now run-unique so a crashed run can never poison the next one's expectations.
This commit is contained in:
@@ -258,14 +258,17 @@ async function save() {
|
||||
const json = await res.json();
|
||||
const status = document.getElementById('saveStatus');
|
||||
if (json.ok) {
|
||||
// Refresh the optimistic-lock fingerprint with the server-computed hash of
|
||||
// the saved content, so the user's own subsequent saves don't trip 409.
|
||||
if (json.contentHash) CONTENT_HASH = json.contentHash;
|
||||
status.textContent = '✅ Mentve!';
|
||||
status.style.color = '#10b981';
|
||||
} else {
|
||||
status.textContent = '❌ Hiba: ' + json.error;
|
||||
status.style.color = '#f87171';
|
||||
}
|
||||
status.style.display = 'inline';
|
||||
setTimeout(() => status.style.display = 'none', 3000);
|
||||
status.style.visibility = 'visible';
|
||||
setTimeout(() => status.style.visibility = 'hidden', 3000);
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
@@ -274,7 +277,11 @@ async function publish() {
|
||||
|
||||
// Save first
|
||||
await save();
|
||||
|
||||
|
||||
// WHY: lock the button width and remember the label so the running state
|
||||
// neither resizes the bottom bar nor permanently swaps the env-specific label.
|
||||
const originalLabel = btn.textContent;
|
||||
btn.style.minWidth = btn.offsetWidth + 'px';
|
||||
btn.textContent = '⏳ Élesítés folyamatban...';
|
||||
btn.disabled = true;
|
||||
|
||||
@@ -295,10 +302,11 @@ async function publish() {
|
||||
status.style.color = '#f87171';
|
||||
}
|
||||
|
||||
btn.textContent = '🚀 Publikálás & Élesítés';
|
||||
btn.textContent = originalLabel;
|
||||
btn.style.minWidth = '';
|
||||
btn.disabled = false;
|
||||
status.style.display = 'inline';
|
||||
setTimeout(() => status.style.display = 'none', 5000);
|
||||
status.style.visibility = 'visible';
|
||||
setTimeout(() => status.style.visibility = 'hidden', 5000);
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
|
||||
@@ -66,7 +66,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
|
||||
|
||||
/* Bottom bar */
|
||||
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
|
||||
.btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
|
||||
.btn-logout { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
|
||||
.btn-logout:hover { background: #374151; }
|
||||
.version-tag { color: #475569; font-size: 12px; font-family: monospace; }
|
||||
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
||||
@@ -75,9 +75,11 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
|
||||
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
|
||||
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
|
||||
.btn-publish:active { transform: translateY(0); }
|
||||
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; margin-left: auto; }
|
||||
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; }
|
||||
.preview-link:hover { color: #94a3b8; }
|
||||
.save-status { font-size: 13px; font-weight: 500; display: none; margin-left: 8px; }
|
||||
/* WHY: the status slot always occupies the same flex space (visibility, not
|
||||
display) so showing/hiding messages never shifts the other bar items. */
|
||||
.save-status { flex: 1 1 0; min-width: 0; margin: 0 8px; font-size: 13px; font-weight: 500; visibility: hidden; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* Toast */
|
||||
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
|
||||
@@ -124,7 +126,7 @@ ${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${messag
|
||||
const DATA = JSON.parse(document.getElementById('page-data').textContent);
|
||||
const FILE = "${activeFile}";
|
||||
const CSRF_TOKEN = "${csrfToken}";
|
||||
const CONTENT_HASH = "${contentHash}";
|
||||
let CONTENT_HASH = "${contentHash}";
|
||||
|
||||
${clientJs}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Layout guard for the CMS bottom bar (MITHOME-69): the bar items must not
|
||||
* shift while saving/publishing. Asserts the invariants that keep the layout
|
||||
* stable:
|
||||
* - the status slot reserves constant space (flex + visibility, not display)
|
||||
* - no auto margins redistribute free space between bar items
|
||||
* - the publish handler locks the button width and restores its label
|
||||
* - save refreshes the optimistic-lock fingerprint (MITHOME-68)
|
||||
*/
|
||||
const assert = require('assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pages = fs.readFileSync(path.join(__dirname, '../scripts/cms-pages.js'), 'utf8');
|
||||
const client = fs.readFileSync(path.join(__dirname, '../scripts/cms-editor-client.js'), 'utf8');
|
||||
|
||||
const statusRule = pages.match(/\.save-status \{[^}]*\}/)[0];
|
||||
assert.match(statusRule, /flex: 1 1 0/, 'status slot must reserve constant space');
|
||||
assert.match(statusRule, /visibility: hidden/, 'status must hide via visibility (keeps layout slot)');
|
||||
assert.doesNotMatch(statusRule, /display: none/, 'display:none would collapse the slot and shift items');
|
||||
|
||||
const previewRule = pages.match(/\.preview-link \{[^}]*\}/)[0];
|
||||
assert.doesNotMatch(previewRule, /margin-left: auto/, 'auto margins redistribute space on width changes');
|
||||
const logoutRule = pages.match(/\.btn-logout \{[^}]*\}/)[0];
|
||||
assert.doesNotMatch(logoutRule, /margin-left: auto/, 'auto margins redistribute space on width changes');
|
||||
|
||||
assert.match(client, /btn\.style\.minWidth = btn\.offsetWidth \+ 'px'/, 'publish must lock the button width');
|
||||
assert.match(client, /const originalLabel = btn\.textContent/, 'publish must restore the env-specific label');
|
||||
assert.match(client, /if \(json\.contentHash\) CONTENT_HASH = json\.contentHash/, 'save must refresh the lock fingerprint');
|
||||
|
||||
console.log('Content Editor bottom bar layout guard: OK');
|
||||
@@ -67,17 +67,74 @@ async function call(method, pathName, { body, headers } = {}) {
|
||||
async function main() {
|
||||
await waitForServer();
|
||||
const contentFile = path.join(ROOT, 'proto', 'src', 'content', 'pages', 'contact.json');
|
||||
const original = fs.readFileSync(contentFile, 'utf8');
|
||||
|
||||
const login = await fetch(`${BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user: 'conflict-test-user', pass: 'conflict-test-pass' }),
|
||||
});
|
||||
const cookie = (login.headers.get('set-cookie') || '').split(';')[0];
|
||||
const csrfPage = await (await fetch(`${BASE}/?file=contact`, { headers: { Cookie: cookie } })).text();
|
||||
const csrf = csrfPage.match(/CSRF_TOKEN = "([a-f0-9]+)"/)[1];
|
||||
|
||||
// Baseline: read the page and capture the served fingerprint
|
||||
const page = await (await call('GET', '/?file=contact')).text();
|
||||
const servedHash = page.match(/const CONTENT_HASH = "([a-f0-9]+)"/)[1];
|
||||
const servedHash = page.match(/let CONTENT_HASH = "([a-f0-9]+)"/)[1];
|
||||
const diskBefore = fs.readFileSync(contentFile, 'utf8');
|
||||
assert.equal(servedHash, hashOf(diskBefore), 'served fingerprint matches the file on disk');
|
||||
|
||||
const payload = diskBefore; // unchanged content is still a valid save payload
|
||||
const jsonHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
// 1. correct hash → 200
|
||||
// MITHOME-68: the save response returns the new content hash; a tab that
|
||||
// adopts it can save again — only a genuinely external change may 409.
|
||||
const v3 = JSON.parse(original);
|
||||
v3.hero.subtitle = 'Hash frissítés teszt ' + Date.now();
|
||||
const first = await fetch(`${BASE}/save?file=contact`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
'X-Content-Hash': hashOf(fs.readFileSync(contentFile, 'utf8')),
|
||||
},
|
||||
body: JSON.stringify(v3, null, 2),
|
||||
});
|
||||
assert.equal(first.status, 200);
|
||||
const firstBody = await first.json();
|
||||
assert.match(firstBody.contentHash, /^[a-f0-9]{64}$/, 'save must return the new content hash');
|
||||
|
||||
// same tab continues with the returned hash → 200
|
||||
const again = await fetch(`${BASE}/save?file=contact`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
'X-Content-Hash': firstBody.contentHash,
|
||||
},
|
||||
body: JSON.stringify(v3, null, 2),
|
||||
});
|
||||
assert.equal(again.status, 200, 'save with the refreshed hash must succeed');
|
||||
|
||||
// a stale (pre-save) hash still 409s
|
||||
const stale = await fetch(`${BASE}/save?file=contact`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf,
|
||||
'X-Content-Hash': hashOf(original),
|
||||
},
|
||||
body: JSON.stringify(v3, null, 2),
|
||||
});
|
||||
assert.equal(stale.status, 409, 'stale hash must still be rejected');
|
||||
|
||||
// leave the disk as the following sections expect it
|
||||
fs.writeFileSync(contentFile, original);
|
||||
|
||||
// 1. correct hash → 200
|
||||
const ok = await call('POST', '/save?file=contact', {
|
||||
headers: { ...jsonHeaders, 'X-Content-Hash': servedHash },
|
||||
body: payload,
|
||||
|
||||
@@ -26,7 +26,7 @@ const fixture = {
|
||||
};
|
||||
const clientJs = fs.readFileSync('scripts/cms-editor-client.js', 'utf8');
|
||||
const html = serverContext.globalThis.renderContentEditor('home', JSON.stringify(fixture), null, 'csrf-test-token', { common: '⚙️ Közös' }, clientJs, 'hash-test-value');
|
||||
assert.ok(html.includes('const CONTENT_HASH = "hash-test-value"'));
|
||||
assert.ok(html.includes('let CONTENT_HASH = "hash-test-value"'));
|
||||
const browserSource = [...html.matchAll(/<script(?: [^>]*)?>([\s\S]*?)<\/script>/g)].at(-1)[1]
|
||||
.replace("render(DATA, document.getElementById('editor'));", '')
|
||||
.replace("const toast = document.querySelector('.toast');", 'const toast = null;');
|
||||
|
||||
Reference in New Issue
Block a user