CI/CD / preconditions (pull_request) Successful in 1s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m9s
CI/CD / test_and_check_app (pull_request) Successful in 6m40s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
209 lines
7.2 KiB
JavaScript
209 lines
7.2 KiB
JavaScript
import { createApp, h, nextTick, reactive, ref } from 'vue';
|
|
import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue';
|
|
import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue';
|
|
import { createLocalization } from '../../resources/js/lib/localization';
|
|
import '../../resources/css/app.css';
|
|
|
|
const original = 'First line\nSecond line\n\n**Existing bold**';
|
|
const state = reactive({
|
|
value: original,
|
|
disabled: false,
|
|
error: '',
|
|
result: 'Running browser checks…',
|
|
updates: 0,
|
|
});
|
|
const component = ref(null);
|
|
const app = createApp({
|
|
render: () =>
|
|
h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [
|
|
h(
|
|
'h1',
|
|
{ class: 'text-xl font-semibold' },
|
|
'Ticket description editor',
|
|
),
|
|
h('p', { id: 'test-result', role: 'status' }, state.result),
|
|
h(TicketDescriptionEditor, {
|
|
ref: component,
|
|
id: 'description',
|
|
label: 'Description',
|
|
modelValue: state.value,
|
|
disabled: state.disabled,
|
|
error: state.error,
|
|
'onUpdate:modelValue': (value) => {
|
|
state.value = value;
|
|
state.updates++;
|
|
},
|
|
}),
|
|
h('h2', { class: 'font-semibold' }, 'Saved description preview'),
|
|
h(TicketDescription, { description: state.value }),
|
|
]),
|
|
}).use(createLocalization());
|
|
app.mount('#app');
|
|
const tick = async () => {
|
|
await nextTick();
|
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
};
|
|
const assert = (condition, message) => {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
};
|
|
const button = (name) =>
|
|
[...document.querySelectorAll('button')].find(
|
|
(element) =>
|
|
element.getAttribute('aria-label') === name ||
|
|
element.textContent.trim() === name,
|
|
);
|
|
const click = async (name) => {
|
|
const element = button(name);
|
|
assert(element && !element.disabled, `Button available: ${name}`);
|
|
element.click();
|
|
await tick();
|
|
};
|
|
try {
|
|
await tick();
|
|
const editor = component.value.$.setupState.editor;
|
|
assert(editor, 'Editor mounted');
|
|
assert(
|
|
state.value === original && state.updates === 0,
|
|
'Mount preserves exact source',
|
|
);
|
|
assert(
|
|
document
|
|
.querySelector('#description')
|
|
.getAttribute('contenteditable') === 'true',
|
|
'Visual textbox is editable',
|
|
);
|
|
assert(
|
|
editor.getText().includes('First line\nSecond line'),
|
|
'Legacy newlines retained',
|
|
);
|
|
state.value = 'Format this';
|
|
await tick();
|
|
editor.commands.selectAll();
|
|
for (const name of ['Bold', 'Italic', 'Strikethrough']) {
|
|
await click(name);
|
|
assert(
|
|
button(name).getAttribute('aria-pressed') === 'true',
|
|
`${name} active`,
|
|
);
|
|
}
|
|
assert(
|
|
state.value.includes('**') && state.value.includes('~~'),
|
|
'Formatting emits Markdown',
|
|
);
|
|
await click('Undo');
|
|
assert(!editor.isActive('strike'), 'Undo restores prior state');
|
|
await click('Redo');
|
|
assert(editor.isActive('strike'), 'Redo restores formatting');
|
|
await click('Markdown');
|
|
const source = document.querySelector('textarea#description');
|
|
assert(source.value === state.value, 'Source matches the editor output');
|
|
source.value = '## From source\n\n- One\n- Two';
|
|
source.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await tick();
|
|
await click('Visual editor');
|
|
assert(
|
|
document.querySelector('#description h2')?.textContent ===
|
|
'From source',
|
|
'Source updates visual editor',
|
|
);
|
|
assert(
|
|
document.querySelectorAll('#description li').length === 2,
|
|
'Lists render after source switch',
|
|
);
|
|
for (const [name, selector] of [
|
|
['Bullet list', 'ul li'],
|
|
['Numbered list', 'ol li'],
|
|
['Quote', 'blockquote'],
|
|
['Inline code', 'code'],
|
|
['Code block', 'pre code'],
|
|
['Heading 2', 'h2'],
|
|
['Heading 3', 'h3'],
|
|
]) {
|
|
state.value = `Check ${name}`;
|
|
await tick();
|
|
editor.commands.selectAll();
|
|
await click(name);
|
|
assert(
|
|
document.querySelector(`#description ${selector}`),
|
|
`${name} renders`,
|
|
);
|
|
}
|
|
state.value = 'Link text';
|
|
await tick();
|
|
editor.commands.selectAll();
|
|
await click('Add or edit link');
|
|
assert(
|
|
document.activeElement.id === 'description-link',
|
|
'Link field receives focus',
|
|
);
|
|
const input = document.querySelector('#description-link');
|
|
input.value = 'javascript:alert(1)';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await tick();
|
|
await click('Apply link');
|
|
assert(
|
|
document
|
|
.querySelector('#description-link-error')
|
|
.textContent.includes('Enter a valid'),
|
|
'Unsafe link rejected',
|
|
);
|
|
input.value = 'https://example.test/docs';
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await tick();
|
|
await click('Apply link');
|
|
assert(
|
|
state.value === '[Link text](https://example.test/docs)',
|
|
'Selection becomes a Markdown link',
|
|
);
|
|
await click('Remove link');
|
|
assert(state.value === 'Link text', 'Link can be removed');
|
|
state.disabled = true;
|
|
state.error = 'Description rejected.';
|
|
await tick();
|
|
assert(
|
|
document
|
|
.querySelector('#description')
|
|
.getAttribute('contenteditable') === 'false',
|
|
'Processing disables editing',
|
|
);
|
|
assert(
|
|
document.querySelector('#description').getAttribute('aria-invalid') ===
|
|
'true',
|
|
'Server error reflected on editor',
|
|
);
|
|
assert(
|
|
button('Bold').disabled && button('Markdown').disabled,
|
|
'Processing disables controls',
|
|
);
|
|
assert(state.value === 'Link text', 'Validation retains draft');
|
|
state.disabled = false;
|
|
state.error = '';
|
|
state.value = 'Reset draft';
|
|
await tick();
|
|
assert(editor.getText() === 'Reset draft', 'External reset syncs editor');
|
|
editor.commands.selectAll();
|
|
await click('Heading 1');
|
|
assert(state.value.startsWith('# '), 'Heading command saves Markdown');
|
|
editor.commands.clearContent();
|
|
await tick();
|
|
assert(state.value === '', 'Clearing editor saves an empty description');
|
|
state.value = '| A | B |\n| - | - |\n| a | b |';
|
|
await tick();
|
|
assert(
|
|
document.querySelector('textarea#description').value === state.value,
|
|
'Unsupported content retained as source',
|
|
);
|
|
assert(button('Visual editor').disabled, 'Lossy conversion is unavailable');
|
|
state.value =
|
|
'# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.';
|
|
await tick();
|
|
state.result =
|
|
'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.';
|
|
} catch (error) {
|
|
state.result = `FAILED: ${error.message}`;
|
|
console.error(error);
|
|
}
|
|
window.addEventListener('pagehide', () => app.unmount(), { once: true });
|