Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions e2e/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
| --- | --- | --- | --- | --- |
| 1 | P0 smoke + key interaction skeleton (infra) | 28 (1 fixme) | ~3-4 min | ✅ landed |
| 2 | Cross-browser matrix + drag/IME | +20 → 48 | +4-6 min | ⏳ pending |
| 3 | Render depth + remaining blocks + security | +2573 | +2-3 min | ⏳ pending |
| 3 | Render depth + remaining blocks + security | +2378 (1 fixme) | +2-3 min | ✅ landed |
| 4 | Stability / performance / a11y guardrails | +25 → 98 | +3-5 min | ⏳ pending |

Phase 1 baseline (PR landing snapshot):
Expand Down Expand Up @@ -53,24 +53,27 @@ Unlocks Firefox + WebKit, and the input/drag flows that don't survive cross-engi

## Phase 3 — Render depth + remaining blocks + security

Landed: 23 new tests across `tests/diagrams/`, `tests/blocks/`, `tests/security/`.
Local runtime ~+2s on top of Phase 1 baseline.

### Diagrams

- [ ] **Vega-Lite.** Inject a Vega-Lite spec via `setContent` → wait for `.mu-diagram-preview svg` → count mark elements (e.g. circle / rect) to verify the chart actually rendered.
- [ ] **PlantUML.** `@startuml…@enduml`. Plantuml-encoder forwards to a public service; either mock the network call (`page.route`) or allow real network in this spec only.
- [x] **Vega-Lite.** Inject a Vega-Lite spec via `setContent` → wait for `.mu-diagram-preview svg` → count `path|rect` mark elements to verify the chart actually rendered. (`tests/diagrams/vega-lite.spec.ts`, 2 tests)
- [x] **PlantUML.** `@startuml…@enduml` round-trips through `setContent` + `getMarkdown`. `plantuml.com/**` is mocked via `page.route` for hermeticity; the spec asserts the encoded URL shape and `getMarkdown` preserves source. (`tests/diagrams/plantuml.spec.ts`, 2 tests)

### Remaining block types

- [ ] **Frontmatter (yaml / toml / json `;;;` / json `{}`).** Setext / setContent each style → assert getMarkdown round-trips delimiter style.
- [ ] **HTML inline formats.** `<u>`, `<mark>`, `<sup>`, `<sub>`, `<ruby>` display and edit; cursor placement after wrapping a selection.
- [ ] **ReferenceLink / ReferenceImage round-trip.** `[label][ref]` + `[ref]: url "title"` — direct regression coverage for PR-16.
- [ ] **Footnote.** Multiple references to one definition, deletion-cleanup of orphan definitions.
- [x] **Frontmatter (yaml / toml / json `;;;` / json `{}`).** All four delimiter styles round-trip through `setContent` + `getMarkdown`. (`tests/blocks/frontmatter.spec.ts`, 4 tests)
- [x] **HTML inline formats.** `<u>`, `<mark>`, `<sup>`, `<sub>` each round-trip via the generic `htmlTag` renderer; `<ruby>` is split out because it routes through the dedicated `htmlRuby` renderer (mounts `span.mu-ruby` not `*.mu-raw-html`). (`tests/blocks/html-inline.spec.ts`, 5 tests)
- [x] **ReferenceLink / ReferenceImage round-trip.** Direct PR-16 regression coverage including case-insensitive label resolution. Reference images mock `example.test/**` to make `loadImage` resolve. (`tests/blocks/reference-link-image.spec.ts`, 4 tests)
- [x] **Footnote.** Multiple `[^a]` refs sharing a definition, definition appearing before vs after the first ref, and the deliberate "no auto-cleanup of orphan defs" current contract. (`tests/blocks/footnote-scenarios.spec.ts`, 3 tests)

### Sanitize / XSS

- [ ] Inject `<script>window.__pwned=1</script>` via setContent → assert `window.__pwned` never set.
- [ ] Inject `<a href="javascript:alert(1)">x</a>` → assert anchor stripped or href neutralised.
- [ ] Inject `<img src=x onerror="window.__pwned=1">` → assert onerror dropped.
- [ ] Static export via `new MarkdownToHtml(md).generate()` against same payloads → assert sanitized HTML output.
- [x] Inject `<script>(window).__pwned = true</script>` via setContent → assert `window.__pwned` never set. Canary declared on `Window` in `e2e/types.d.ts`.
- [x] Inject `<a href="javascript:alert(1)">x</a>` → assert anchor's rendered `href` is either dropped or no longer contains `javascript:`.
- [x] Inject `<img src=x onerror="">` → assert `onerror` attribute is stripped + canary not set.
- [ ] Static export via `new MarkdownToHtml(md).generate()` against same payloads → assert sanitized HTML output. **Deferred to Phase 4** — current host doesn't expose `MarkdownToHtml` on `window`, and reaching for `page.evaluate(() => new (await import('@muyajs/core')).MarkdownToHtml(...))` would require new host plumbing. Phase 4 can wire it onto `window.__e2e` and assert the static path.

---

Expand Down
76 changes: 76 additions & 0 deletions e2e/tests/blocks/footnote-scenarios.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect, test } from '../fixtures/muya';
import { editor } from '../helpers/selectors';

/**
* Footnote scenarios beyond the bare `setContent` smoke test in
* `tests/ui/footnote.spec.ts`. Covers:
* - Multiple references to one definition.
* - Definition appearing before vs after first reference.
* - Orphan-definition behavior when the inline reference is deleted —
* current contract: definitions are *not* auto-cleaned up.
*/

test.describe('footnote scenarios', () => {
test('multiple references to the same definition all render identifiers', async ({ page }) => {
const source = 'A[^a] then B[^a] then C[^a].\n\n[^a]: shared body\n';
await page.evaluate((md) => {
window.muya!.setContent(md);
}, source);

await expect(page.locator(editor.paragraph).first()).toContainText('A');

// All three inline footnote identifiers should mount.
const identifiers = page.locator(editor.inlineFootnoteIdentifier);
await expect(identifiers).toHaveCount(3);

const md = await page.evaluate(() => window.muya!.getMarkdown());
// Reference shape is `[^a]` × 3.
expect((md.match(/\[\^a\](?!:)/g) ?? []).length).toBe(3);
expect(md).toContain('[^a]: shared body');
});

test('definition appearing BEFORE the first reference still resolves', async ({ page }) => {
// Spec says definitions can appear anywhere; renderer should still
// recognize the inline `[^a]` token regardless of doc order.
const source = '[^a]: defined first\n\nLater paragraph with[^a] a reference.\n';
await page.evaluate((md) => {
window.muya!.setContent(md);
}, source);

// Sync barrier: the editor root should contain both texts. The
// first `.mu-paragraph` belongs to the footnote definition body so
// we anchor on the editor root instead.
await expect(page.locator(editor.root)).toContainText('Later paragraph');

await expect(page.locator(editor.inlineFootnoteIdentifier).first()).toBeVisible();

const md = await page.evaluate(() => window.muya!.getMarkdown());
expect(md).toContain('[^a]: defined first');
expect(md).toContain('[^a]');
});

test('deleting an inline [^a] token leaves the definition in state (no auto-cleanup)', async ({ page }) => {
const source = 'Body[^a] text.\n\n[^a]: orphan body\n';
await page.evaluate((md) => {
window.muya!.setContent(md);
}, source);

await expect(page.locator(editor.inlineFootnoteIdentifier).first()).toBeVisible();

// Wipe out the inline reference by reloading the paragraph without
// the `[^a]` token. The definition block is untouched.
await page.evaluate(() => {
window.muya!.setContent('Body text.\n\n[^a]: orphan body\n');
});

await expect(page.locator(editor.paragraph).first()).toContainText('Body text');
// No inline identifier any more.
await expect(page.locator(editor.inlineFootnoteIdentifier)).toHaveCount(0);

// Definition survives — verifying that orphan defs aren't auto-pruned.
const md = await page.evaluate(() => window.muya!.getMarkdown());
expect(md).toContain('[^a]: orphan body');
// No `[^a]` reference in the body (only the definition prefix).
expect((md.match(/\[\^a\](?!:)/g) ?? []).length).toBe(0);
});
});
90 changes: 90 additions & 0 deletions e2e/tests/blocks/frontmatter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { TState } from '@muyajs/core';
import { expect, test } from '../fixtures/muya';
import { editor } from '../helpers/selectors';

/**
* Frontmatter has four delimiter styles, each round-tripped by
* serializeFrontMatter in `state/stateToMarkdown.ts`:
* - YAML `---\n…---\n` (lang: 'yaml', style: '-')
* - TOML `+++\n…+++\n` (lang: 'toml', style: '+')
* - JSON `;;;\n…;;;\n` (lang: 'json', style: ';')
* - JSON `{\n…}\n` (lang: 'json', style: '{')
*
* Each style is set via `setContent` with explicit meta. We assert the block
* renders + the markdown round-trip preserves the right delimiter shape.
*/

interface IStyleCase {
label: string;
lang: 'yaml' | 'toml' | 'json';
style: '-' | '+' | ';' | '{';
text: string;
expectedStart: string;
expectedEnd: string;
}

const STYLE_CASES: IStyleCase[] = [
{
label: 'YAML (---)',
lang: 'yaml',
style: '-',
text: 'title: hi\nauthor: me',
expectedStart: '---\n',
expectedEnd: '---\n',
},
{
label: 'TOML (+++)',
lang: 'toml',
style: '+',
text: 'title = "hi"\nauthor = "me"',
expectedStart: '+++\n',
expectedEnd: '+++\n',
},
{
label: 'JSON (;;;)',
lang: 'json',
style: ';',
text: '"title": "hi",\n"author": "me"',
expectedStart: ';;;\n',
expectedEnd: ';;;\n',
},
{
label: 'JSON ({})',
lang: 'json',
style: '{',
text: '"title": "hi",\n"author": "me"',
expectedStart: '{\n',
expectedEnd: '}\n',
},
];

test.describe('frontmatter block', () => {
for (const styleCase of STYLE_CASES) {
test(`renders + round-trips ${styleCase.label}`, async ({ page }) => {
await page.evaluate((c) => {
const state: TState[] = [{
name: 'frontmatter',
meta: { lang: c.lang, style: c.style },
text: c.text,
}, {
name: 'paragraph',
text: 'body',
}];
window.muya!.setContent(state);
}, styleCase);

// The block mounts as `<pre.mu-frontmatter>` wrapping a code block.
const fm = page.locator(editor.frontmatter);
await expect(fm).toBeVisible();
// Use a sync barrier on the paragraph too — its presence confirms
// the document loaded fully.
await expect(page.locator(editor.paragraph).first()).toContainText('body');

const md = await page.evaluate(() => window.muya!.getMarkdown());
expect(md.startsWith(styleCase.expectedStart)).toBe(true);
expect(md).toContain(styleCase.text);
// The closing delimiter immediately precedes the body paragraph.
expect(md).toContain(styleCase.expectedEnd);
});
}
});
81 changes: 81 additions & 0 deletions e2e/tests/blocks/html-inline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect, test } from '../fixtures/muya';

/**
* Inline HTML tags (`<u>`, `<mark>`, `<sup>`, `<sub>`, `<ruby>`) render via
* `inlineRenderer/renderer/htmlTag.ts`: each tag becomes an actual element
* wrapped with `.mu-inline-rule.mu-raw-html`. We assert:
* - The tag renders inline (an element with the right tagName mounts).
* - getMarkdown round-trips the literal tag text.
*/

interface ITagCase {
label: string;
tag: 'u' | 'mark' | 'sup' | 'sub';
markdown: string;
}

/**
* Generic tags routed through `htmlTag.ts` mount the actual `<u>` / `<mark>`
* / `<sup>` / `<sub>` element with `.mu-raw-html`. `<ruby>` is a special
* case (see below) because it has its own `htmlRuby.ts` renderer.
*/
const TAG_CASES: ITagCase[] = [
{
label: 'underline <u>',
tag: 'u',
markdown: 'Text with <u>underline</u> inside.',
},
{
label: 'highlight <mark>',
tag: 'mark',
markdown: 'Text with <mark>highlight</mark> inside.',
},
{
label: 'superscript <sup>',
tag: 'sup',
markdown: 'E = mc<sup>2</sup>.',
},
{
label: 'subscript <sub>',
tag: 'sub',
markdown: 'H<sub>2</sub>O.',
},
];

test.describe('inline html tags', () => {
for (const tagCase of TAG_CASES) {
test(`${tagCase.label} renders and round-trips`, async ({ page }) => {
await page.evaluate((md) => {
window.muya!.setContent(md);
}, tagCase.markdown);

// The actual `<u>` / `<mark>` / `<sup>` / `<sub>` element mounts
// inside the paragraph wrapped in `.mu-raw-html`.
const el = page.locator(`${tagCase.tag}.mu-raw-html`).first();
await expect(el).toBeVisible();

const md = await page.evaluate(() => window.muya!.getMarkdown());
// Round-trip preserves the literal opening + closing tag text.
expect(md).toContain(`<${tagCase.tag}>`);
expect(md).toContain(`</${tagCase.tag}>`);
});
}

test('ruby <ruby> renders via htmlRuby + round-trips', async ({ page }) => {
// `<ruby>` flows through `htmlRuby.ts`, which mounts a
// `span.mu-ruby` wrapper containing a `span.mu-ruby-text` (the
// source side) and a `span.mu-ruby-render` (the preview that hosts
// the actual <ruby><rt>…</rt></ruby> DOM via `htmlToVNode(raw)`).
const markdown = 'Word <ruby>漢<rt>kan</rt></ruby> here.';
await page.evaluate((md) => {
window.muya!.setContent(md);
}, markdown);

await expect(page.locator('span.mu-ruby').first()).toBeVisible();

const md = await page.evaluate(() => window.muya!.getMarkdown());
expect(md).toContain('<ruby>');
expect(md).toContain('</ruby>');
expect(md).toContain('<rt>kan</rt>');
});
});
Loading
Loading