283 lines
10 KiB
Dart
283 lines
10 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:mews/mews/document.dart';
|
|
import 'package:mews/mews/parser.dart';
|
|
|
|
const conformingPage = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
|
|
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
|
<head>
|
|
<title>Signal Bars</title>
|
|
<meta name="mews-profile" content="0.1" />
|
|
<meta name="viewport" content="width=device-width" />
|
|
<meta name="description" content="Notes on old phones." />
|
|
<link rel="alternate" type="application/atom+xml" href="feed.xml" title="Posts" />
|
|
</head>
|
|
<body class="mews-green">
|
|
<h1>Signal Bars</h1>
|
|
<p>Notes on <em>old phones</em> and <a href="about.html">the about page</a>.</p>
|
|
<ul>
|
|
<li><a href="2026-10-08-retro-phones.html">2026-10-08 Collecting retro phones</a></li>
|
|
<li><a href="2026-09-21-first-post.html">2026-09-21 First post</a></li>
|
|
</ul>
|
|
</body>
|
|
</html>
|
|
''';
|
|
|
|
void main() {
|
|
test('parses a conforming Mews page', () {
|
|
final page = parseMewsPage(source: conformingPage, url: 'https://example.com/index.html');
|
|
|
|
expect(page.isMews, isTrue);
|
|
expect(page.specVersion, '0.1');
|
|
expect(page.title, 'Signal Bars');
|
|
expect(page.description, 'Notes on old phones.');
|
|
expect(page.variant, MewsVariant.green);
|
|
expect(page.lang, 'en');
|
|
|
|
expect(page.entries, hasLength(2));
|
|
expect(page.entries.first.url, 'https://example.com/2026-10-08-retro-phones.html');
|
|
expect(page.entries.first.title, 'Collecting retro phones');
|
|
expect(page.entries.first.date, DateTime(2026, 10, 8));
|
|
// Entries are listed newest first.
|
|
expect(page.entries.last.date, DateTime(2026, 9, 21));
|
|
|
|
expect(page.body, hasLength(3));
|
|
expect(page.body[0], isA<HeadingBlock>());
|
|
final paragraph = page.body[1] as ParagraphBlock;
|
|
expect(paragraph.inline, hasLength(5));
|
|
final link = paragraph.inline[3] as MewsLink;
|
|
expect(link.href, 'https://example.com/about.html');
|
|
});
|
|
|
|
test('rejects a page that is not well-formed', () {
|
|
expect(
|
|
() => parseMewsPage(
|
|
source: '<html><head><title>x</title></head><body><p>oops</body></html>',
|
|
url: 'https://example.com/'),
|
|
throwsA(isA<MewsParseException>()),
|
|
);
|
|
});
|
|
|
|
test('treats a page without the conformance marker as non-Mews', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Plain</title></head>
|
|
<body><p>Just XHTML.</p></body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/plain.html');
|
|
expect(page.isMews, isFalse);
|
|
expect(page.specVersion, isNull);
|
|
expect(page.variant, MewsVariant.plain);
|
|
});
|
|
|
|
test('resolves relative URLs against the page', () {
|
|
final page = parseMewsPage(source: conformingPage, url: 'https://example.com/posts/index.html');
|
|
final paragraph = page.body[1] as ParagraphBlock;
|
|
final link = paragraph.inline[3] as MewsLink;
|
|
expect(link.href, 'https://example.com/posts/about.html');
|
|
});
|
|
|
|
test('allows same-site images and refuses off-site and data URIs', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Pics</title></head>
|
|
<body>
|
|
<p><img src="/cat.png" alt="A cat" /> and <img src="https://img.example.com/dog.png" alt="A dog" /></p>
|
|
<p><img src="https://other.example.net/bird.png" alt="A bird" /></p>
|
|
<p><img src="data:image/png;base64,AAAA" alt="Inline" /></p>
|
|
</body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/pics.html');
|
|
final images = page.body.whereType<ImageBlock>().toList();
|
|
expect(images, hasLength(4));
|
|
expect(images[0].src, 'https://example.com/cat.png');
|
|
expect(images[0].allowed, isTrue);
|
|
expect(images[1].src, 'https://img.example.com/dog.png');
|
|
expect(images[1].allowed, isTrue, reason: 'subdomains share the registered domain');
|
|
expect(images[2].allowed, isFalse, reason: 'another site');
|
|
expect(images[3].allowed, isFalse, reason: 'data URI');
|
|
});
|
|
|
|
test('drops scripts and styles entirely', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Spooky</title><script>alert(1)</script></head>
|
|
<body><p>Keep this.</p><script>var x = 1;</script><style>p{color:red}</style></body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/spooky.html');
|
|
expect(page.body, hasLength(1));
|
|
final paragraph = page.body[0] as ParagraphBlock;
|
|
expect((paragraph.inline[0] as MewsText).text, 'Keep this.');
|
|
});
|
|
|
|
test('honors dir=rtl', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml" dir="rtl" lang="he">
|
|
<head><title>Shalom</title></head>
|
|
<body><p>Text.</p></body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/he.html');
|
|
expect(page.direction, TextDirection.rtl);
|
|
});
|
|
|
|
test('builds form fields with labels', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Guestbook</title></head>
|
|
<body>
|
|
<form action="/sign" method="post">
|
|
<label for="name">Your name</label>
|
|
<input type="text" id="name" name="name" />
|
|
<input type="hidden" name="page" value="1" />
|
|
<input type="submit" value="Sign guestbook" />
|
|
</form>
|
|
</body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/guest.html');
|
|
final form = page.body.single as FormBlock;
|
|
expect(form.action, 'https://example.com/sign');
|
|
expect(form.method, 'post');
|
|
final nameField = form.fields[0] as MewsTextField;
|
|
expect(nameField.name, 'name');
|
|
expect(nameField.label, 'Your name');
|
|
final hidden = form.fields[1] as MewsHiddenField;
|
|
expect(hidden.value, '1');
|
|
final submit = form.fields[2] as MewsSubmitField;
|
|
expect(submit.label, 'Sign guestbook');
|
|
});
|
|
|
|
test('mewsPlainText recovers text from broken markup', () {
|
|
const broken = '<html><head><title>Half</title></head><body><p>Visible & intact.</p>';
|
|
final text = mewsPlainText(broken);
|
|
expect(text, contains('Visible & intact.'));
|
|
});
|
|
|
|
test('pretty-printed markup leaves no phantom blank content', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Indent</title><meta name="mews-profile" content="0.1" /></head>
|
|
<body>
|
|
<blockquote>
|
|
<p>No blank line above this.</p>
|
|
<p>And none between paragraphs.</p>
|
|
</blockquote>
|
|
<ul>
|
|
<li>
|
|
<a href="2026-10-08-post.html">2026-10-08 A dated link</a>
|
|
</li>
|
|
</ul>
|
|
<p>
|
|
A paragraph with
|
|
wrapped lines.
|
|
</p>
|
|
</body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/indent.html');
|
|
|
|
// The quote holds its two paragraphs and nothing else.
|
|
final quote = page.body[0] as QuoteBlock;
|
|
expect(quote.children, hasLength(2));
|
|
expect((quote.children.first as ParagraphBlock).inline.single,
|
|
isA<MewsText>());
|
|
|
|
// The list item starts directly with the link, no blank run before it.
|
|
final list = page.body[1] as ListBlock;
|
|
final item = (list.items.single.blocks.single as ParagraphBlock).inline;
|
|
final link = item.single as MewsLink;
|
|
expect((link.children.single as MewsText).text, '2026-10-08 A dated link');
|
|
|
|
// Interior line breaks collapse to single spaces, like in browsers.
|
|
final paragraph = (page.body[2] as ParagraphBlock).inline;
|
|
expect((paragraph.single as MewsText).text, 'A paragraph with wrapped lines.');
|
|
});
|
|
|
|
test('keeps bare inline content in list items and quotes', () {
|
|
final source = '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head><title>Bare</title><meta name="mews-profile" content="0.1" /></head>
|
|
<body>
|
|
<ul>
|
|
<li><a href="2026-10-08-post.html">2026-10-08 A dated link</a></li>
|
|
<li>Plain item</li>
|
|
<li>Text then a list:<ul><li>Nested</li></ul></li>
|
|
</ul>
|
|
<blockquote>Quoted words <em>verbatim</em>.</blockquote>
|
|
</body>
|
|
</html>
|
|
''';
|
|
final page = parseMewsPage(source: source, url: 'https://example.com/bare.html');
|
|
final list = page.body[0] as ListBlock;
|
|
final first = (list.items[0].blocks.single as ParagraphBlock).inline;
|
|
final link = first.single as MewsLink;
|
|
expect(link.href, 'https://example.com/2026-10-08-post.html');
|
|
expect((link.children.single as MewsText).text, '2026-10-08 A dated link');
|
|
|
|
expect((list.items[1].blocks.single as ParagraphBlock).inline.single,
|
|
isA<MewsText>());
|
|
// Text before a nested list stays with the item; the list becomes a block.
|
|
expect(list.items[2].blocks, hasLength(2));
|
|
expect(list.items[2].blocks[0], isA<ParagraphBlock>());
|
|
expect(list.items[2].blocks[1], isA<ListBlock>());
|
|
|
|
final quote = page.body[1] as QuoteBlock;
|
|
final quoteText = (quote.children.single as ParagraphBlock).inline;
|
|
expect(quoteText, hasLength(3));
|
|
expect((quoteText[0] as MewsText).text, 'Quoted words ');
|
|
});
|
|
|
|
group('the atom feed link', () {
|
|
String pageWith(String link) => '''
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<title>Signal Bars</title>
|
|
<meta name="mews-profile" content="0.1" />
|
|
$link
|
|
</head>
|
|
<body><h1>Signal Bars</h1></body>
|
|
</html>
|
|
''';
|
|
|
|
test('resolves against the page, so a relative href is usable', () {
|
|
final page = parseMewsPage(
|
|
source: pageWith(
|
|
'<link rel="alternate" type="application/atom+xml" href="feed.xml" />'),
|
|
url: 'https://example.com/posts/index.html',
|
|
);
|
|
expect(page.atomFeedHref, 'https://example.com/posts/feed.xml');
|
|
});
|
|
|
|
test('is refused when it points at another site', () {
|
|
// A client must not load another site's resources on its own
|
|
// (specs 7.4 and 8.1).
|
|
final page = parseMewsPage(
|
|
source: pageWith(
|
|
'<link rel="alternate" type="application/atom+xml" href="https://elsewhere.test/feed.xml" />'),
|
|
url: 'https://example.com/index.html',
|
|
);
|
|
expect(page.atomFeedHref, isNull);
|
|
});
|
|
|
|
test('is null when the page declares none', () {
|
|
final page = parseMewsPage(
|
|
source: pageWith(''), url: 'https://example.com/index.html');
|
|
expect(page.atomFeedHref, isNull);
|
|
});
|
|
});
|
|
}
|