// Run with: node --test e2e_tests/form-validation.cjs // Every caller of serializeObject() must treat a null result (invalid form) as "do not submit": // stop the loading indicator, keep the form open and let the browser show the offending field. const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { chromium } = require('playwright'); const root = path.resolve(__dirname, '..'); const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); test('invalid forms are rejected instead of submitted with partial data', async () => { const browser = await chromium.launch({ headless: true, channel: process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined) }); try { const page = await browser.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(e.message)); await page.setContent(''); await page.addScriptTag({ content: 'var $fis = {};' }); for (const file of [ 'Fuchs/wwwroot/lib/jquery/jquery.js', 'Fuchs/js/intranet/oci_texts_basic_de.js', 'Fuchs/js/intranet/oci_basic.js', 'Fuchs/js/intranet/oci_mainbase.js', 'Fuchs/js/intranet/oci_main.js', 'Fuchs/js/intranet/fis_texts_gui_de.js', 'Fuchs/js/intranet/fis_main.js' ]) { await page.addScriptTag({ content: read(file) }); } const r = await page.evaluate(() => { const out = {}; const posts = []; const realPostXT = $ocms.postXT; $ocms.postXT = function (o) { posts.push(o); if (o && typeof o.complete === 'function') { o.complete.call(this, { status: 200 }); } }; const ajax = []; const realAjax = $.ajax; $.ajax = (o) => { ajax.push(o); return $.Deferred().promise(); }; /* A required hidden field is never checked by the browser, so serializeObject is the only guard. */ const invalidForm = () => $('
').appendTo('body'); // rejectInvalid: stops the spinner and asks the browser to report the field let reported = 0; const f1 = invalidForm().ldng(1); f1[0].reportValidity = () => { reported++; return false; }; f1.rejectInvalid(); out.rejectStopsLoading = !f1.hasClass('loading'); out.rejectReported = reported; // postXT({ form }) sends nothing for an invalid form realPostXT.call(document, { url: '/x', form: invalidForm() }); out.postXTAjaxCalls = ajax.length; $.ajax = realAjax; // sendForm: no post for an invalid form; a valid one posts and calls options.complete without throwing posts.length = 0; invalidForm().sendForm('/x'); out.sendFormInvalidPosts = posts.length; let completed = null; $('
').appendTo('body').sendForm('/x', null, { complete: (x) => { completed = x; } }); out.sendFormValidPosts = posts.length; out.sendFormCompleteArg = completed; // login.send: no TypeError on an invalid form, nothing posted posts.length = 0; const lf = invalidForm(); out.loginSendResult = $ocms.login.send.call(lf[0], { preventDefault() {} }); out.loginSendPosts = posts.length; // dlg: the "check your input" note exists on every form dialog, even without required fields const d = $ocms.dlg($$.dc('x').append(''), { form: true, confirm: () => {} }); out.noteInvalidWithoutRequired = d.ft.find('.note_invalid').length; d.c.trigger('modal_close'); // dlgform (success mode): invalid form keeps the dialog open and never calls success let succeeded = false; const d2 = $ocms.dlgform([{ name: 'n', type: 'hidden', required: true, value: '' }], { success: () => { succeeded = true; } }); const form2 = $('body > .modal:last form'); form2.trigger('submit'); out.dlgformSucceeded = succeeded; out.dlgformStillOpen = $('body > .modal').length; out.dlgformLoading = form2.hasClass('loading'); out.dlgformInvalidNoteShown = form2.hasClass('invalid') && form2.find('.note_invalid').length; form2.trigger('modal_close'); // password change: mismatch must not leave the spinner on or lock the form for good window.confirm = () => true; posts.length = 0; $fis.resetPass(); const pf = $('body > .modal:last form'); pf.find('[name=opw]').val('old-password'); pf.find('[name=npw]').val('new-password-1'); pf.find('[name=npwc]').val('new-password-2'); pf.find('[name=code]').val('123456'); pf.trigger('submit'); const npw = pf.find('[name=npw]')[0]; out.mismatchPosts = posts.filter((p) => /changepassword/.test(p.url)).length; out.mismatchLoading = pf.hasClass('loading'); out.mismatchMessage = npw.validationMessage; pf.find('[name=npwc]').val('new-password-1').trigger('input'); out.afterEditValid = npw.checkValidity(); pf.trigger('submit'); out.matchPosts = posts.filter((p) => /changepassword/.test(p.url)).length; return out; }); assert.deepEqual(errors, []); assert.equal(r.rejectStopsLoading, true); assert.equal(r.rejectReported, 1); assert.equal(r.postXTAjaxCalls, 0, 'postXT must not send an invalid form'); assert.equal(r.sendFormInvalidPosts, 0, 'sendForm must not send an invalid form'); assert.equal(r.sendFormValidPosts, 1); assert.deepEqual(r.sendFormCompleteArg, { status: 200 }, 'sendForm passes the jqXHR to options.complete'); assert.equal(r.loginSendResult, false); assert.equal(r.loginSendPosts, 0); assert.equal(r.noteInvalidWithoutRequired, 1); assert.equal(r.dlgformSucceeded, false); assert.equal(r.dlgformStillOpen, 1); assert.equal(r.dlgformLoading, false); assert.ok(r.dlgformInvalidNoteShown); assert.equal(r.mismatchPosts, 0); assert.equal(r.mismatchLoading, false, 'spinner must stop on a password mismatch'); assert.equal(r.mismatchMessage, 'Die Passwörter stimmen nicht überein'); assert.equal(r.afterEditValid, true, 'editing a password field must clear the mismatch error'); assert.equal(r.matchPosts, 1); } finally { await browser.close(); } });