thenavisapp
Version:
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
234 lines (193 loc) • 7.81 kB
text/typescript
import * as assert from 'node:assert';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ParserError, Token } from 'parse5';
import { type Tokenizer, TokenizerMode, type TokenHandler } from 'parse5';
import { makeChunks } from './common.js';
export type HtmlLibToken = [string, string | null, ...unknown[]];
interface TokenError {
code: string;
line: number;
col: number;
}
interface TokenSourceData {
tokens: HtmlLibToken[];
errors: TokenError[];
}
type TokenSourceCreator = (data: TokenizeHandler) => Tokenizer;
/** Receives events and immediately compares them against the expected values. We check the entire output again at the end. */
class TokenizeHandler implements TokenSourceData, TokenHandler {
constructor(private testData: LoadedTest) {}
private addToken(token: HtmlLibToken): void {
assert.deepStrictEqual(token, this.testData.expected[this.tokens.length]);
this.tokens.push(token);
}
onComment(token: Token.CommentToken): void {
this.addToken(['Comment', token.data]);
}
onDoctype(token: Token.DoctypeToken): void {
this.addToken(['DOCTYPE', token.name, token.publicId, token.systemId, !token.forceQuirks]);
}
onStartTag(token: Token.TagToken): void {
const reformatedAttrs = Object.fromEntries(token.attrs.map(({ name, value }) => [name, value]));
const startTagEntry: HtmlLibToken = ['StartTag', token.tagName, reformatedAttrs];
if (token.selfClosing) {
startTagEntry.push(true);
}
this.addToken(startTagEntry);
}
onEndTag(token: Token.TagToken): void {
// NOTE: parser feedback simulator can produce adjusted SVG
// tag names for end tag tokens so we need to lower case it
this.addToken(['EndTag', token.tagName.toLowerCase()]);
}
onEof(): void {
this.sawEof = true;
}
onCharacter(token: Token.CharacterToken): void {
const lastEntry = this.tokens[this.tokens.length - 1];
if (lastEntry && lastEntry[0] === 'Character' && lastEntry[1] != null) {
lastEntry[1] += token.chars;
} else {
this.tokens.push(['Character', token.chars]);
}
const actual = this.tokens[this.tokens.length - 1];
const expected = this.testData.expected[this.tokens.length - 1];
assert.strictEqual('Character', expected[0]);
assert.ok(typeof actual[1] === 'string');
assert.ok(expected[1]?.startsWith(actual[1]));
}
onNullCharacter(token: Token.CharacterToken): void {
this.onCharacter(token);
}
onWhitespaceCharacter(token: Token.CharacterToken): void {
this.onCharacter(token);
}
onParseError(err: ParserError): void {
assert.ok(
this.testData.expectedErrors.some(
({ code, line, col }) => code === err.code && line === err.startLine && col === err.startCol,
),
);
this.errors.push({
code: err.code,
line: err.startLine,
col: err.startCol,
});
}
public sawEof = false;
public tokens: HtmlLibToken[] = [];
public errors: TokenError[] = [];
}
function tokenize(createTokenSource: TokenSourceCreator, chunks: string[], testData: LoadedTest): TokenSourceData {
const result = new TokenizeHandler(testData);
const tokenizer = createTokenSource(result);
// NOTE: set small waterline for testing purposes
tokenizer.preprocessor.bufferWaterline = 8;
tokenizer.state = testData.initialState;
if (testData.lastStartTag) {
tokenizer.lastStartTagName = testData.lastStartTag;
}
for (let i = 0; i < chunks.length; i++) {
assert.ok(!result.sawEof);
tokenizer.write(chunks[i], i === chunks.length - 1);
}
assert.ok(result.sawEof);
assert.ok(!tokenizer.active);
// Sort errors by line and column
result.errors.sort((err1, err2) => err1.line - err2.line || err1.col - err2.col);
return result;
}
function unicodeUnescape(str: string): string {
return str.replace(/\\[Uu]\w{4}/g, (match: string) => String.fromCharCode(Number.parseInt(match.slice(2), 16)));
}
function unescapeDescrIO(testDescr: TestDescription): void {
testDescr.input = unicodeUnescape(testDescr.input);
for (const tokenEntry of testDescr.output) {
//NOTE: unescape token tagName (for StartTag and EndTag tokens), comment data (for Comment token),
//character token data (for Character token).
if (tokenEntry[1]) {
tokenEntry[1] = unicodeUnescape(tokenEntry[1]);
}
}
}
function getTokenizerSuitableStateName(testDataStateName: string): Tokenizer['state'] {
const name = testDataStateName.slice(0, -6).replace(' ', '_').toUpperCase();
return TokenizerMode[name as keyof typeof TokenizerMode];
}
interface TestDescription {
initialStates: string[];
doubleEscaped?: boolean;
output: HtmlLibToken[];
description: string;
input: string;
lastStartTag: string;
errors?: TokenError[];
}
interface LoadedTest {
idx: number;
setName: string;
name: string;
input: string;
expected: HtmlLibToken[];
initialState: Tokenizer['state'];
initialStateName: string;
lastStartTag: string;
expectedErrors: TokenError[];
}
function loadTests(dataDirPath: string): LoadedTest[] {
const testSetFileNames = fs.readdirSync(dataDirPath);
const tests: LoadedTest[] = [];
let testIdx = 0;
for (const fileName of testSetFileNames) {
if (path.extname(fileName) !== '.test') {
continue;
}
const filePath = path.join(dataDirPath, fileName);
const testSetJson = fs.readFileSync(filePath).toString();
const testSet = JSON.parse(testSetJson);
const testDescrs: TestDescription[] = testSet.tests;
if (!testDescrs) {
continue;
}
const setName = fileName.replace('.test', '');
for (const descr of testDescrs) {
if (!descr.initialStates) {
descr.initialStates = ['Data state'];
}
if (descr.doubleEscaped) {
unescapeDescrIO(descr);
}
const expected = descr.output;
for (const initialStateName of descr.initialStates) {
tests.push({
idx: ++testIdx,
setName,
name: descr.description,
input: descr.input,
expected,
initialState: getTokenizerSuitableStateName(initialStateName),
initialStateName,
lastStartTag: descr.lastStartTag,
expectedErrors: descr.errors || [],
});
}
}
}
return tests;
}
export function generateTokenizationTests(
prefix: string,
testSuite: string,
createTokenSource: TokenSourceCreator,
): void {
for (const testData of loadTests(testSuite)) {
const testName = `${prefix} - ${testData.idx}.${testData.setName} - ${testData.name} - Initial state: ${testData.initialStateName}`;
it(testName, () => {
const chunks = makeChunks(testData.input);
const result = tokenize(createTokenSource, chunks, testData);
assert.deepEqual(result.tokens, testData.expected, `Chunks: ${JSON.stringify(chunks)}`);
assert.deepEqual(result.errors, testData.expectedErrors || []);
});
}
}