This commit is contained in:
Mateusz Gruszczyński committed 2026-10-12 12:19:33 +02:00
1 parent acfe926066
commit df403c0aa7
26 files changed
+1502 -144

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
globalThis.location = {origin:'https://example.org'};
let source = readFileSync('static/js/markdown.js','utf8')
.replace(/^import .*;$/gm, '');
source = `const EMOJI_SHORTCODES = {}; const t = (key,vars,alternative) => alternative; const parseImageAlias = () => null;\n` + source;
const {renderMarkdown, setMarkdownMentions} = await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
function has(re, text, message) { assert.match(text,re,message); }
let html = renderMarkdown('# Instrukcja testowa\n[Przejdź](#instrukcja-testowa)\n# Instrukcja testowa\n[TOC]\n## Etap drugi');
has(/<h1 id="instrukcja-testowa"/,html,'first anchor');
has(/<h1 id="instrukcja-testowa-2"/,html,'duplicate heading anchor');
has(/href="#instrukcja-testowa"/,html,'link to section');
has(/heading-permalink/,html,'visible link icon');
has(/href="#etap-drugi"/,html,'TOC anchor');
html = renderMarkdown(' ```js\n const value = 1;\n console.log(value);\n ```\nafter');
has(/<pre[^>]*style="margin-left:2ch"[^>]*><code class="language-javascript">const value = 1;\n console\.log\(value\);<\/code><\/pre>/,html,'code container indentation + dedent');
has(/<p[^>]*>after<\/p>/,html,'code closed');
html = renderMarkdown(' ```python=42\n print(42)\n ```');
has(/class="code-with-lines[^>]*style="margin-left:4ch"/,html,'numbered fenced code whole block shifted');
has(/data-line="42">print\(42\)/,html,'numbered content dedented');
html = renderMarkdown(' ```\n ~~~\n ```');
has(/<code>~~~<\/code>/,html,'other marker must remain inside code');
html = renderMarkdown('```\n## hidden in code\n```\n## Visible');
assert.doesNotMatch(html, /id="hidden-in-code"/);
has(/id="visible"/,html,'headings outside code');
setMarkdownMentions([{nickname:'Ala',color:'#aabbcc'},{nickname:'John+Dev',color:'red'}]);
html = renderMarkdown('Cześć @Ala i @John+Dev! @Other @Alana a@Ala `@Ala` [@Ala](https://site.test)');
assert.equal((html.match(/class="markdown-mention"/g)||[]).length,2,'valid mentions only');
has(/style="--owner:#aabbcc"/,html,'real author color');
has(/@Other @Alana a@Ala/,html,'nonmembers and suffixes not chips');
has(/<code>@Ala<\/code>/,html,'inline code excluded');
has(/<a href="https:\/\/site.test\/"[^>]*>@Ala<\/a>/,html,'links excluded');
assert.doesNotMatch(renderMarkdown('```\n@Ala\n```'), /markdown-mention/);
html = renderMarkdown('Sentence @Ala. Domain @Ala.example');
assert.equal((html.match(/class="markdown-mention"/g) || []).length, 1, 'punctuation after a mention is allowed, domains are not');
setMarkdownMentions([]);
assert.doesNotMatch(renderMarkdown('@Ala'), /markdown-mention/);
console.log('PASS: anchors, TOC, duplicate headings, indented fences, numbered blocks, mentions, exclusions');
html = renderMarkdown('[TOC]\n# Procedura\n<details>\n<summary>Szczegóły</summary>\n## Procedura\n</details>');
has(/id="procedura"/,html);
has(/id="procedura-2"/,html,'nested heading anchor unique');
has(/href="#procedura-2"/,html,'TOC link for heading inside details');
console.log('PASS: anchors in nested details match TOC');
setMarkdownMentions([{nickname: 'a[1]\\x', color: '#123456'}, {nickname: '<f>&"', color: '#112233'}]);
html = renderMarkdown('Hello @a[1]\\x and @<f>&" but not @a[1]\\xy');
assert.equal((html.match(/class="markdown-mention"/g)||[]).length, 2, 'escaped nicknames');
assert.doesNotMatch(html, /<f>/, 'no raw HTML from nickname');
console.log('PASS: special-character nicknames remain escaped');
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Browser checks for @mention suggestions. Run from repository root."""
from __future__ import annotations
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
HTML = '''<!doctype html><html lang="pl"><head>
<meta charset="utf-8">
__STYLES__
<style>
body { padding: 18px; }
.editor-shell { width: min(580px, 90vw); height: 340px; }
#editor { height: 100%; }
</style></head><body>
<div class="editor-shell">
<div class="line-gutter"></div>
<textarea id="editor" wrap="off" spellcheck="false"></textarea>
</div>
<script type="module">
__SCRIPT__
window.autocomplete = createMentionAutocomplete(document.querySelector('#editor'));
window.mentionInputTypes = [];
document.querySelector('#editor').addEventListener('input', e => window.mentionInputTypes.push(e.inputType));
</script></body></html>'''
def main():
source = (ROOT / 'static/js/mention-autocomplete.js').read_text()
source = source.replace('import { t } from "@rustpad/i18n";', 'const t = (key, vars, fallback) => fallback;')
styles = (ROOT / 'static/css/styles.css').read_text()
html = HTML.replace('__STYLES__', f'<style>{styles}</style>').replace('__SCRIPT__', source)
with sync_playwright() as playwright:
browser = playwright.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])
try:
page = browser.new_page(viewport={"width": 870, "height": 550})
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
page.set_content(html)
page.wait_for_function("!!window.autocomplete")
editor = page.locator('#editor')
options = page.locator('.mention-suggestions__option')
page.evaluate('''() => window.autocomplete.setUsers([
{nickname:'Dominik',color:'#ff0000'},
{nickname:'Dora',color:'#00ff00'},
{nickname:'Jan Domański',color:'#ff8800'},
{nickname:'Anna',color:'red'}
])''')
editor.fill('Witaj @do')
assert options.count() == 3, options.all_text_contents()
assert options.all_text_contents() == ['@Dominik', '@Dora', '@Jan Domański']
assert editor.get_attribute('aria-expanded') == 'true'
assert options.nth(0).get_attribute('aria-selected') == 'true'
editor.press('ArrowDown')
assert options.nth(1).get_attribute('aria-selected') == 'true'
editor.press('Enter')
assert editor.input_value() == 'Witaj @Dora '
assert page.evaluate('window.mentionInputTypes.at(-1)') == 'insertReplacementText'
assert page.locator('.mention-suggestions').is_hidden()
editor.fill('Test @do')
editor.press('ArrowDown')
editor.press('ArrowDown')
editor.press('Tab')
assert editor.input_value() == 'Test @Jan Domański '
editor.fill('@d')
assert options.count() == 3
editor.press('Escape')
assert page.locator('.mention-suggestions').is_hidden()
editor.press('ArrowDown')
assert page.locator('.mention-suggestions').is_hidden(), 'Escape must dismiss the query'
editor.fill('@do')
options.first.click()
assert editor.input_value() == '@Dominik '
editor.fill('user@example.org')
assert page.locator('.mention-suggestions').is_hidden(), 'email should not trigger suggestions'
editor.fill('hello @unknown')
assert page.locator('.mention-suggestions').is_hidden(), 'only authorized members should be shown'
editor.fill('Hi @')
assert options.count() == 4
page.evaluate('window.autocomplete.setUsers([])')
assert page.locator('.mention-suggestions').is_hidden(), 'clear users on logout'
editor.fill('Hi @do')
assert page.locator('.mention-suggestions').is_hidden(), 'no guest suggestions'
page.evaluate('window.autocomplete.setUsers([{nickname:"Dominik",color:"#ff0000"}])')
editor.fill('Hi @do')
assert options.count() == 1
geometry = page.locator('.mention-suggestions').bounding_box()
shell_geometry = page.locator('.editor-shell').bounding_box()
assert geometry['x'] >= shell_geometry['x'] - 1
assert geometry['y'] >= shell_geometry['y'] - 1
assert geometry['x'] + geometry['width'] <= shell_geometry['x'] + shell_geometry['width'] + 1
assert geometry['y'] + geometry['height'] <= shell_geometry['y'] + shell_geometry['height'] + 1
editor.fill('Edycja @do.')
page.evaluate('''() => {let e = document.querySelector('#editor'); e.setSelectionRange(10,10); e.dispatchEvent(new Event('keyup'));}''')
assert options.count() == 1
editor.press('Enter')
assert editor.input_value() == 'Edycja @Dominik.' # no unnecessary space before punctuation
assert not errors, errors
print('PASS: Chromium editor completion, keyboard/mouse, privacy, positioning, punctuation')
finally:
browser.close()
if __name__ == '__main__':
main()
+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
let source = readFileSync('static/js/mention-autocomplete.js', 'utf8');
source = source.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, vars, fallback) => fallback;');
const { mentionAtCaret, filterMentionUsers, normalizeMentionUsers } = await import(
'data:text/javascript;base64,' + Buffer.from(source).toString('base64')
);
assert.deepEqual(mentionAtCaret('Cześć @do', 9), { start: 6, end: 9, query: 'do' });
assert.deepEqual(mentionAtCaret('@', 1), { start: 0, end: 1, query: '' });
assert.deepEqual(mentionAtCaret('Pierwsza linia\nWitaj (@Żół', 26), { start: 22, end: 26, query: 'Żół' });
assert.equal(mentionAtCaret('john@example.org', 12), null, 'do not suggest in addresses');
assert.equal(mentionAtCaret('abc@do', 6), null, 'no mention inside a word');
assert.equal(mentionAtCaret('Hello @do ', 10), null, 'space ends a query');
assert.equal(mentionAtCaret('Hello @do-more', 14)?.query, 'do-more', 'support dashes');
assert.equal(mentionAtCaret('Hello @@do', 10), null, 'double at is not a mention');
assert.equal(mentionAtCaret('Hello @do', -1), null);
const members = normalizeMentionUsers([
{ nickname: 'Dominik', color: '#ff0000', email: 'secret@example.org' },
{ nickname: 'DOMINIK', color: '#aaaaaa' },
{ nickname: 'Dora', color: 'red' },
{ nickname: 'Jan Domański', color: '#00ff00' },
{ nickname: '', color: '#00ff00' },
{ nickname: 'Joanna', color: '#123abc' },
]);
assert.equal(members.length, 4, 'deduplicate case insensitive, skip empty');
assert.deepEqual(filterMentionUsers(members, 'do').map(user => user.nickname), ['Dominik', 'Dora', 'Jan Domański']);
assert.equal(filterMentionUsers(members, 'DO')[0].nickname, 'Dominik');
assert.equal(filterMentionUsers(members, 'zz').length, 0);
assert.equal(members[0].color, '#ff0000');
assert.match(members[1].color, /^hsl\(/, 'invalid user color falls back safely');
assert.equal(Object.hasOwn(members[0], 'email'), false, 'no email addresses in UI model');
console.log('PASS: mention context, filtering, normalization, privacy');
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Browser integration test for the mentioned-users bar and cyclic jumps."""
from __future__ import annotations
import base64
from pathlib import Path
from playwright.sync_api import sync_playwright
ROOT = Path(__file__).resolve().parents[1]
HTML = '''<!doctype html><html lang="pl"><head><meta charset="utf-8">
<style>__STYLES__</style>
<style>
body { padding: 20px; }
.workspace { width: min(970px, calc(100vw - 40px)); height: 400px; grid-template-columns: 1fr 1fr; }
.editor-column { display: flex; flex-direction: column; height: 100%; border: 1px solid #aaa; }
.preview-column { display: grid; min-width: 0; min-height: 0; }
.editor-shell { flex: 1 1 auto; min-height: 0; }
#editor { box-sizing: border-box; width: 100%; height: 100%; padding: 18px; line-height: 22px; }
#preview { box-sizing: border-box; width: 100%; height: 100%; overflow: auto; border: 1px solid #aaa; padding: 18px; }
</style>
</head><body class="pad-page">
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label">Editor</div>
<div class="participant-badges"><span class="participant-badge">Autor</span></div>
<div id="mentioned-users" class="mentioned-users" role="group" hidden>
<span class="mentioned-users__label">Wspomniani</span>
<div id="mentioned-users-list" class="mentioned-users__list"></div>
</div>
<div class="editor-shell"><textarea id="editor" wrap="off"></textarea></div>
</div>
<div class="preview-column">
<div id="preview-label" class="column-label">Markdown preview</div>
<div id="mentioned-users-preview" class="mentioned-users mentioned-users--preview" role="group" hidden>
<span class="mentioned-users__label">Wspomniani</span>
<div id="mentioned-users-preview-list" class="mentioned-users__list"></div>
</div>
<article class="markdown-body preview" id="preview"></article>
</div>
</div>
<script type="module">
import {createMentionNavigator} from '__NAVIGATION_URL__';
import {setMarkdownMentions,renderMarkdown} from '__MARKDOWN_URL__';
const editor = document.querySelector('#editor');
const preview = document.querySelector('#preview');
let view = 'split', members = [];
const navigator = createMentionNavigator({
container: document.querySelector('#mentioned-users'),
list: document.querySelector('#mentioned-users-list'),
previewContainer: document.querySelector('#mentioned-users-preview'),
previewList: document.querySelector('#mentioned-users-preview-list'),
editor, preview, getView: () => view, onEditorScroll: () => {},
});
window.runCase = (source, users) => {
editor.value = source; members = users;
navigator.setUsers(members);
setMarkdownMentions(members);
preview.innerHTML = renderMarkdown(source);
navigator.render({markdown:true});
};
window.setView = value => {
view=value;
document.querySelector('#editor-workspace').className = `workspace view-${value}`;
};
window.setUsers = value => {
navigator.setUsers(value);
setMarkdownMentions(value);
preview.innerHTML = renderMarkdown(editor.value);
navigator.render({markdown:true});
};
window.ready = true;
</script></body></html>'''
def as_data_url(source):
return 'data:text/javascript;base64,' + base64.b64encode(source.encode('utf-8')).decode('ascii')
def create_fixture():
autocomplete = (ROOT / 'static/js/mention-autocomplete.js').read_text()
autocomplete = autocomplete.replace('import { t } from "@rustpad/i18n";',
'const t = (key, values, fallback) => fallback;')
navigation = (ROOT / 'static/js/mention-navigation.js').read_text()
navigation = navigation.replace('import { t } from "@rustpad/i18n";',
'const t = (key, values, fallback) => fallback;')
navigation = navigation.replace('"@rustpad/mention-autocomplete"',
f'"{as_data_url(autocomplete)}"')
markdown = (ROOT / 'static/js/markdown.js').read_text()
markdown = '\n'.join(line for line in markdown.split('\n') if not line.startswith('import '))
markdown = ('const EMOJI_SHORTCODES = {}; '
'const t = (key, values, fallback) => fallback; '
'const parseImageAlias = () => null;\n') + markdown
styles = (ROOT / 'static/css/styles.css').read_text()
return (HTML.replace('__STYLES__', styles)
.replace('__NAVIGATION_URL__', as_data_url(navigation))
.replace('__MARKDOWN_URL__', as_data_url(markdown)))
def main():
fixture = create_fixture()
with sync_playwright() as playwright:
browser = playwright.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox'])
try:
page = browser.new_page(viewport={'width': 1020, 'height': 680})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
page.set_content(fixture)
page.wait_for_function('window.ready === true')
lines = [
'Na początku @Dominik i @Dominik, do tego @Dora.',
'`@Dominik` [@Dominik](https://example.com/@Dora)',
'```sh',
'@Dominik',
'```',
*[f'Wypełnienie linii {i}' for i in range(1, 18)],
'<details>',
'<summary>Szczegóły</summary>',
'Wewnątrz @Dominik',
'</details>',
*[f'Treść linii {i}' for i in range(18, 38)],
'Na końcu @Dominik.',
]
content = '\n'.join(lines)
users = [{'nickname': 'Dominik', 'color': '#123456'}, {'nickname':'Dora', 'color':'#654321', 'email':'private@example.org'}]
page.evaluate('([text,users]) => window.runCase(text,users)', [content, users])
bar = page.locator('#mentioned-users')
assert bar.is_visible()
badges = page.locator('#mentioned-users-list .mentioned-users__badge')
preview_badges = page.locator('#mentioned-users-preview-list .mentioned-users__badge')
assert page.locator('#mentioned-users-preview').is_hidden()
assert badges.count() == 2
assert preview_badges.count() == 2
assert badges.all_text_contents() == ['@Dominik4', '@Dora1']
assert 'private' not in bar.inner_text()
button = badges.first
# The two code/link occurrences are intentionally skipped.
source_starts = [content.index('@Dominik'),content.index('@Dominik',content.index('@Dominik')+1),content.index('@Dominik',content.index('Wewnątrz')), content.rindex('@Dominik')]
for step, start in enumerate(source_starts, 1):
button.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == start, step
assert page.evaluate('document.querySelector("#editor").selectionEnd') == start + len('@Dominik')
assert page.locator('.markdown-mention.is-mention-target').count() == 1
assert button.locator('.mentioned-users__count').inner_text() == f'{step}/4'
assert page.locator('details.markdown-details').evaluate('element => element.open') is True
button.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[0]
assert button.locator('.mentioned-users__count').inner_text() == '1/4'
dora = badges.nth(1)
dora.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == content.index('@Dora')
assert dora.locator('.mentioned-users__count').inner_text() == '1/1'
page.evaluate('window.setView("preview")')
assert bar.is_hidden()
assert page.locator('#mentioned-users-preview').is_visible()
assert page.locator('#preview').is_visible()
original_selection = page.evaluate('document.querySelector("#editor").selectionStart')
preview_badges.first.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == original_selection
assert page.locator('.markdown-mention.is-mention-target').count() == 1
page.evaluate('window.setView("edit")')
assert bar.is_visible()
assert page.locator('#mentioned-users-preview').is_hidden()
button.click()
assert page.evaluate('document.querySelector("#editor").selectionStart') == source_starts[2]
page.evaluate('window.setUsers([])')
assert bar.is_hidden()
assert page.locator('#mentioned-users-preview').is_hidden()
assert badges.count() == 0
assert preview_badges.count() == 0
assert not errors, errors
print('PASS: Chromium mentions list, access, cyclic navigation, source selection, details, view switching')
finally:
browser.close()
if __name__ == '__main__':
main()
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const autocomplete = readFileSync('static/js/mention-autocomplete.js', 'utf8')
.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;');
const autocompleteModule = await import('data:text/javascript;base64,' + Buffer.from(autocomplete).toString('base64'));
const navigation = readFileSync('static/js/mention-navigation.js', 'utf8')
.replace(/^import \{ t \} from "@rustpad\/i18n";$/m, 'const t = (key, params, fallback) => fallback;')
.replace(/^import \{ normalizeMentionUsers \} from "@rustpad\/mention-autocomplete";$/m,
`const normalizeMentionUsers = ${autocompleteModule.normalizeMentionUsers.toString()};`);
const { collectMentionOccurrences } = await import('data:text/javascript;base64,' + Buffer.from(navigation).toString('base64'));
const users = [
{nickname:'Dominik',color:'#1256aa',email:'private@example.org'},
{nickname:'dOMINIK',color:'#abc123'},
{nickname:'Dora',color:'#dd00dd'},
{nickname:'Ala+Dev',color:'#123abc'},
];
function matches(source, options) { return collectMentionOccurrences(source, users, options); }
function valid(source, options) { return matches(source, options).map(mention => source.slice(mention.start, mention.end)); }
assert.deepEqual(valid('Tu @Dominik, @Dora oraz @dominik.'), ['@Dominik', '@Dora', '@dominik']);
assert.deepEqual(valid('Przykład @DominiK @Ala+Dev, @Dominik'), ['@DominiK', '@Ala+Dev', '@Dominik']);
assert.deepEqual(valid('john@Dominik.org a@Dora @@Dominik @Dominika @Other'), []);
assert.deepEqual(valid('`@Dominik` i @Dora [@Dominik](https://example.org) oraz https://example.org/@Dora'), ['@Dora']);
assert.deepEqual(valid('[file=secret@Dominik,key] i [image=image@Dora] oraz @Dominik'), ['@Dominik']);
assert.deepEqual(valid('```sh\n@Dominik\n```\nPo @Dora\n~~~\n@Dora\n~~~\n@Dominik'), ['@Dora', '@Dominik']);
assert.deepEqual(valid(' ```js\n@Dominik\n ```\n@Dominik'), ['@Dominik']);
assert.deepEqual(valid('````rust\n@Dominik\n```\n@Dora\n````\n@Dora'), ['@Dora']);
assert.deepEqual(valid('@Dora', { markdown:false }), ['@Dora']);
assert.deepEqual(valid('```\n@Dominik\n```', { markdown:false }), ['@Dominik']);
const source = '🙂 @Dominik\nDrugi @Dora i @Dominik';
const items = matches(source);
assert.deepEqual(items.map(item=>item.start), [source.indexOf('@Dominik'),source.indexOf('@Dora'),source.lastIndexOf('@Dominik')]);
assert.deepEqual(items.map(item=>item.sourceLine), [1,2,2]);
assert.ok(items.every(item => !Object.hasOwn(item, 'email')));
assert.equal(collectMentionOccurrences('Hej @Dominik', []).length, 0);
console.log('PASS: mention navigation positions, permissions, Markdown exclusions, UTF-16, code fences');