core/test-worker: Update snapshot format to be markdownish
MODIFY
packages/@romejs/core/test-worker/SnapshotManager.ts
packages/@romejs/core/test-worker/SnapshotManager.ts
@@ -13,22 +13,32 @@ import {
exists,
unlink,
} from '@romejs/fs';
-import {Dict} from '@romejs/typescript-helpers';
import {TestRunnerOptions} from '../master/testing/types';
import TestWorkerRunner from './TestWorkerRunner';
import {PartialDiagnosticAdvice} from '@romejs/diagnostics';
+import createSnapshotParser from './SnapshotParser';
const SNAPSHOTS_DIR = '__rsnapshots__';
-const SNAPSHOT_EXT = '.rsnap';
-export default class SnapshotManager {
- constructor(runner: TestWorkerRunner, testFilename: AbsoluteFilePath) {
- this.testFilename = testFilename.join();
+function cleanHeading(key: string): string {
+ if (key[0] === '`') {
+ key = key.slice(1);
+ }
+
+ if (key[key.length - 1] === '`') {
+ key = key.slice(-1);
+ }
+
+ return key.trim();
+}
- const folder = testFilename.getParent().append(SNAPSHOTS_DIR);
+export default class SnapshotManager {
+ constructor(runner: TestWorkerRunner, testPath: AbsoluteFilePath) {
+ const folder = testPath.getParent().append(SNAPSHOTS_DIR);
this.folder = folder;
- this.path = folder.append(testFilename.getBasename() + SNAPSHOT_EXT);
+ this.path = folder.append(testPath.getExtensionlessBasename() + '.md');
+ this.testPath = testPath;
this.runner = runner;
this.options = runner.options;
@@ -39,7 +49,7 @@ export default class SnapshotManager {
this.entries = new Map();
}
- testFilename: string;
+ testPath: AbsoluteFilePath;
folder: AbsoluteFilePath;
path: AbsoluteFilePath;
entries: Map<string, string>;
@@ -75,10 +85,41 @@ export default class SnapshotManager {
const file = await readFileText(snapshotFilename);
this.raw = file;
- const json = JSON.parse(file);
+ const parser = createSnapshotParser({
+ path: snapshotFilename,
+ input: file,
+ });
+
+ const nodes = parser.parse();
+
+ while (nodes.length > 0) {
+ const node = nodes.shift();
+ if (node === undefined) {
+ throw new Error('Impossible');
+ }
+
+ if (node.type === 'Heading' && node.level === 1) {
+ // Title
+ continue;
+ }
+
+ if (node.type === 'Heading' && node.level === 2) {
+ const codeBlock = nodes.shift();
+ if (codeBlock === undefined || codeBlock.type !== 'CodeBlock') {
+ throw parser.unexpected({
+ message: 'Expected a code block after this heading',
+ loc: node.loc,
+ });
+ }
- for (const key in json) {
- this.entries.set(key, String(json[key]));
+ this.entries.set(cleanHeading(node.text), codeBlock.value);
+ continue;
+ }
+
+ throw parser.unexpected({
+ message: 'Unexpected node',
+ loc: node.loc,
+ });
}
}
@@ -101,7 +142,9 @@ export default class SnapshotManager {
}
// Build the snapshot
- const json: Dict<string> = {};
+ let lines: Array<string> = [];
+ lines.push(`# \`${this.testPath.getBasename()}\``);
+ lines.push('');
// Get keys and sort them so they're in a predictable order
const keys = Array.from(this.entries.keys()).sort();
@@ -112,10 +155,16 @@ export default class SnapshotManager {
throw new Error('Impossible');
}
- json[key] = value;
+ lines.push(`## \`${key}\``);
+ lines.push('');
+ lines.push('```');
+ // TODO escape triple backquotes
+ lines.push(value);
+ lines.push('```');
+ lines.push('');
}
- const formatted = JSON.stringify(json, undefined, ' ');
+ const formatted = lines.join('\n');
if (this.options.freezeSnapshots) {
if (!this.exists) {

ADD
packages/@romejs/core/test-worker/SnapshotParser.ts
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import {
+ createParser,
+ ParserOptions,
+ BaseTokens,
+ NodeBase,
+ ValueToken,
+ isEscaped,
+} from '@romejs/parser-core';
+import {add, get0, Number0, inc} from '@romejs/ob1';
+
+type Tokens = BaseTokens & {
+ Hashes: ValueToken<'Hashes', number>;
+ CodeBlock: ValueToken<'CodeBlock', string>;
+ TextLine: ValueToken<'TextLine', string>;
+};
+
+type HeadingNode = NodeBase & {
+ type: 'Heading';
+ text: string;
+ level: number;
+};
+
+type CodeBlockNode = NodeBase & {
+ type: 'CodeBlock';
+ value: string;
+};
+
+type Node = HeadingNode | CodeBlockNode;
+
+function isHash(char: string): boolean {
+ return char === '#';
+}
+
+function isCodeBlockEnd(index: Number0, input: string): boolean {
+ return (
+ input[get0(index)] === '`' &&
+ !isEscaped(index, input) &&
+ input[get0(add(index, 1))] === '`' &&
+ input[get0(add(index, 2))] === '`'
+ );
+}
+
+function isInCodeBlock(char: string, index: Number0, input: string): boolean {
+ return !isCodeBlockEnd(index, input);
+}
+
+function isntNewline(char: string): boolean {
+ return char !== '\n';
+}
+
+function unescapeTicks(code: string): string {
+ return code;
+}
+
+export default createParser(
+ ParserCore =>
+ class SnapshotParser extends ParserCore<Tokens, void> {
+ constructor(opts: ParserOptions) {
+ super(opts, 'snapshots');
+ }
+
+ tokenize(index: Number0, input: string) {
+ const char = input[get0(index)];
+
+ switch (char) {
+ case '#':
+ const [hashes] = this.readInputFrom(index, isHash);
+ const level = hashes.length;
+ return this.finishValueToken('Hashes', level, add(index, level));
+
+ case ' ':
+ case '\t':
+ case '\r':
+ case '\n':
+ return this.lookaheadToken(inc(index));
+
+ case '`':
+ const nextChar = input[get0(add(index, 1))];
+ const nextNextChar = input[get0(add(index, 2))];
+
+ if (nextChar === '`' && nextNextChar === '`') {
+ let codeOffset = add(index, 3);
+
+ // Expect the first offset character to be a newline
+ if (input[get0(codeOffset)] === '\n') {
+ // Skip leading newline
+ codeOffset = add(codeOffset, 1);
+ } else {
+ throw this.unexpected({
+ message: 'Newline required after code block',
+ start: this.getPositionFromIndex(codeOffset),
+ });
+ }
+
+ let [code] = this.readInputFrom(codeOffset, isInCodeBlock);
+
+ let end = add(codeOffset, code.length);
+
+ if (isCodeBlockEnd(end, input)) {
+ // Check for trailing newline
+ if (code[code.length - 1] === '\n') {
+ // Trim trailing newline
+ code = code.slice(-1);
+
+ // Skip closing ticks
+ end = add(end, 3);
+
+ return this.finishValueToken(
+ 'CodeBlock',
+ unescapeTicks(code),
+ end,
+ );
+ } else {
+ throw this.unexpected({
+ message: 'Newline required before code block end',
+ start: this.getPositionFromIndex(end),
+ });
+ }
+ } else {
+ throw this.unexpected({
+ message: 'Unclosed code block',
+ start: this.getPositionFromIndex(end),
+ });
+ }
+ }
+ }
+
+ const [text] = this.readInputFrom(index, isntNewline);
+ return this.finishValueToken('TextLine', text, add(index, text.length));
+ }
+
+ parse(): Array<Node> {
+ const nodes: Array<Node> = [];
+
+ while (!this.isEOF()) {
+ const start = this.getPosition();
+ const token = this.getToken();
+
+ switch (token.type) {
+ case 'Hashes':
+ const level = token.value;
+ this.nextToken();
+ const text = this.expectToken('TextLine').value;
+ nodes.push({
+ type: 'Heading',
+ level,
+ text,
+ loc: this.finishLoc(start),
+ });
+ break;
+
+ case 'CodeBlock':
+ nodes.push({
+ type: 'CodeBlock',
+ value: token.value,
+ loc: this.finishLoc(start),
+ });
+ this.nextToken();
+ break;
+
+ default:
+ throw this.unexpected();
+ }
+ }
+
+ return nodes;
+ }
+ },
+);

MODIFY
packages/@romejs/core/test-worker/TestAPI.ts
packages/@romejs/core/test-worker/TestAPI.ts
@@ -529,7 +529,8 @@ export default class TestAPI {
) {
const key = this.snapshotManager.toSnapshotKey(this.testName, name);
- const formatted = prettyFormat(expected);
+ const formatted =
+ typeof expected === 'string' ? expected : prettyFormat(expected);
// Get the current snapshot
const existingSnapshot = this.snapshotManager.get(key);

core/test-worker: Update snapshot format to be markdownish