diff --git a/lib/tap/deps-test.ts b/lib/tap/deps-test.ts index bb4d60b0..baf47ba4 100644 --- a/lib/tap/deps-test.ts +++ b/lib/tap/deps-test.ts @@ -1,4 +1,3 @@ // Dependencies required during engineering/testing only (not in production) export * as path from "https://deno.land/std@0.213.0/path/mod.ts"; export * from "https://deno.land/std@0.213.0/assert/mod.ts"; -export * as tapParser from "npm:tap-parser"; diff --git a/lib/tap/fixture-01.tap b/lib/tap/fixture-01.tap index d819a12e..05f476da 100644 --- a/lib/tap/fixture-01.tap +++ b/lib/tap/fixture-01.tap @@ -1,7 +1,18 @@ TAP version 14 1..4 # comment at top of file -ok 1 - Input file opened +ok 1 - Input file opened (with subtests) + +# Subtest: subtests + 1..2 + ok 1 - sub 1 + not ok 2 - sub 2 + --- + message: sub 2 invalid + severity: fatal + ... + + not ok 2 - First line of the input valid --- message: First line invalid @@ -10,6 +21,7 @@ not ok 2 - First line of the input valid got: Flirble expect: Fnible ... + ok 3 - Read the rest of the file # comment 2 not ok 4 - Summarized correctly # TODO Not written yet @@ -17,3 +29,4 @@ not ok 4 - Summarized correctly # TODO Not written yet message: Can't make summary yet severity: todo ... + diff --git a/lib/tap/mod.ts b/lib/tap/mod.ts index c098d40a..ed484cf4 100644 --- a/lib/tap/mod.ts +++ b/lib/tap/mod.ts @@ -1,11 +1,12 @@ import * as yaml from "https://deno.land/std@0.213.0/yaml/stringify.ts"; +import * as safety from "../universal/safety.ts"; -export interface TapNode { - readonly kind: Kind; +export interface TapNode { + readonly nature: Nature; } -export interface TapDirective { - readonly kind: Kind; +export interface TapDirective { + readonly nature: Nature; } export interface VersionNode extends TapNode<"version"> { @@ -15,7 +16,11 @@ export interface VersionNode extends TapNode<"version"> { export interface PlanNode extends TapNode<"plan"> { readonly start: number; readonly end: number; - readonly skip?: Readonly; + readonly skip?: SkipDirective; +} + +export interface MutatablePlanNode extends safety.DeepWriteable { + readonly nextPlanIndex: () => number; } export interface SkipDirective extends TapDirective<"SKIP"> { @@ -42,105 +47,157 @@ export interface FooterNode extends TapDirective<"footer"> { export interface TestCase> extends TapNode<"test-case"> { - readonly index: number; + readonly index?: number; readonly description: string; readonly ok: boolean; - readonly directive?: Readonly; - readonly diagnostic?: Readonly; - readonly subTest?: Readonly>; + readonly directive?: Directive; + readonly diagnostic?: Diagnosable; + readonly subtests?: SubTests; } -export interface SubTest> - extends TapNode<"sub-test"> { - readonly title: string; - readonly body: Iterable>>; +export interface SubTests> { + readonly body: Iterable>; + readonly title?: string; + readonly plan?: PlanNode; } export type TestSuiteElement> = | CommentNode - | TestCase - | SubTest - | NestedTestSuite; - -export interface NestedTestSuite> - extends TapNode<"nested-suite"> { - readonly title: string; - readonly body: Iterable>>; - readonly plan?: Readonly; - readonly diagnostic?: Readonly; -} + | TestCase; export interface TapContent> { - readonly version?: Readonly; - readonly plan?: Readonly; - readonly body: Iterable>>; - readonly footers: Iterable>; + readonly version?: VersionNode; + readonly plan?: PlanNode; + readonly body: Iterable>; + readonly footers: Iterable; } -type InitTestCase> = - & Partial, "index" | "diagnostic">> - & { readonly todo?: string; readonly skip?: string }; - -export class BodyBuilder> { - #plansCount = 0; - readonly content: TestSuiteElement[] = []; - - get plansCount() { - return this.#plansCount; +type InitTestCaseBuilder< + Diagnosable extends Record, + Elaboration extends Diagnosable, +> = + & Partial, "index" | "diagnostic">> + & { + readonly subtests?: ( + bb: BodyBuilder, + ) => Promise> | SubTests; + readonly todo?: string; + readonly skip?: string; + }; + +export class BodyFactory> { + constructor( + readonly nestedBodyBuilder: () => BodyBuilder, + ) { } - testCase( + async testCase( ok: boolean, description: string, - init?: InitTestCase, + init?: InitTestCaseBuilder, ) { const directive = (): Directive | undefined => { - if (init?.todo) return { kind: "TODO", reason: init.todo }; - if (init?.skip) return { kind: "SKIP", reason: init.skip }; + if (init?.todo) return { nature: "TODO", reason: init.todo }; + if (init?.skip) return { nature: "SKIP", reason: init.skip }; return undefined; }; + + let subtests: SubTests | undefined = undefined; + if (init?.subtests) { + let nestedBB: BodyBuilder | undefined = undefined; + nestedBB = this.nestedBodyBuilder() as BodyBuilder; + subtests = await init.subtests(nestedBB); + } + const testCase: TestCase = { - kind: "test-case", + nature: "test-case", ok, description, - index: init?.index ?? ++this.#plansCount, + index: init?.index, directive: directive(), - ...init, + diagnostic: init?.diagnostic, + subtests, }; return testCase; } ok( description: string, - init?: InitTestCase, + init?: InitTestCaseBuilder, ) { - const tc = this.testCase(true, description, init); - this.content.push(tc); - return this; + return this.testCase(true, description, init); } notOk( description: string, - init?: InitTestCase, + init?: InitTestCaseBuilder, + ) { + return this.testCase(false, description, init); + } + + comment(comment: string) { + return { nature: "comment", content: comment } as CommentNode; + } +} + +export class BodyBuilder> { + readonly factory = new BodyFactory(() => + new BodyBuilder() + ); + readonly content: TestSuiteElement[] = []; + + plan() { + const result: PlanNode = { + nature: "plan", + start: 1, + end: this.content.filter((e) => e.nature === "test-case").length, + }; + return result; + } + + async compose( + ...elems: ( + | Promise> + | TestSuiteElement + )[] + ) { + this.content.push(...await Promise.all(elems)); + } + + async ok( + description: string, + init?: InitTestCaseBuilder, ) { - const tc = this.testCase(false, description, init); - this.content.push(tc); + this.content.push(await this.factory.ok(description, init)); + return this; + } + + async notOk( + description: string, + init?: InitTestCaseBuilder, + ) { + this.content.push(await this.factory.notOk(description, init)); return this; } comment(comment: string) { - this.content.push({ kind: "comment", content: comment }); + this.content.push({ nature: "comment", content: comment }); return this; } } export class TapContentBuilder< Diagnosable extends Record = Record, + BB extends BodyBuilder = BodyBuilder, > { - #bb = new BodyBuilder(); + #bb: BB; readonly footers: FooterNode[] = []; - constructor(readonly version = 14) { + constructor( + readonly bbSupplier: () => BB, + readonly version = 14, + ) { + this.#bb = bbSupplier(); } get body() { @@ -148,26 +205,24 @@ export class TapContentBuilder< } async populate( - init: ( - bb: BodyBuilder, - footers: FooterNode[], - ) => void | Promise, + init: (bb: BB, footers: FooterNode[]) => void | Promise, ) { await init(this.body, this.footers); return this; } tapContent(): TapContent { - const body = this.body.content; return { - version: { kind: "version", version: this.version }, - plan: body.length < 1 - ? { kind: "plan", start: -1, end: -1 } - : { kind: "plan", start: 1, end: this.body.plansCount }, - body, + version: { nature: "version", version: this.version }, + plan: this.body.plan(), + body: this.body.content, footers: this.footers, }; } + + static create() { + return new TapContentBuilder(() => new BodyBuilder()); + } } export function stringify>( @@ -175,14 +230,13 @@ export function stringify>( ): string { let result = ""; - // Function to escape special characters function escapeSpecialChars(str: string): string { return str.replace(/#/g, "\\#"); } - // Helper function for processing elements function processElement( element: TestSuiteElement, + nextIndex: () => number, depth: number, ): string { const indent = " ".repeat(depth); @@ -199,47 +253,45 @@ export function stringify>( return `${indent} ---\n${yamlIndented}\n${indent} ...\n`; }; - switch (element.kind) { + switch (element.nature) { case "test-case": { + const index = nextIndex(); const description = escapeSpecialChars(element.description); - elementResult += `${indent}${ - element.ok ? "ok" : "not ok" - } ${element.index} - ${description}`; + elementResult += `${indent}${element.ok ? "ok" : "not ok"} ${ + element.index ?? index + } - ${description}`; if (element.directive) { const directiveReason = escapeSpecialChars(element.directive.reason); - elementResult += ` # ${element.directive.kind} ${directiveReason}`; + elementResult += ` # ${element.directive.nature} ${directiveReason}`; } if (element.diagnostic) { elementResult += `\n${yamlBlock(element.diagnostic)}`; } - elementResult += "\n"; - break; - } - case "sub-test": { - const subTitle = escapeSpecialChars(element.title); - elementResult += `${indent}# Subtest: ${subTitle}\n`; - for (const nestedElement of element.body) { - elementResult += processElement(nestedElement, depth + 1); - } - break; - } - case "nested-suite": { - const suiteTitle = escapeSpecialChars(element.title); - elementResult += `${indent}# Nested Suite: ${suiteTitle}\n`; - if (element.plan) { - elementResult += `${indent}1..${element.plan.end}`; - if (element.plan.skip) { - const planReason = escapeSpecialChars(element.plan.skip.reason); - elementResult += ` # SKIP ${planReason}`; + if (element.subtests) { + const subtests = element.subtests; + if (subtests.title) { + elementResult += `\n\n${indent}# Subtest: ${ + escapeSpecialChars(subtests.title) + }\n`; + } + if (subtests.plan) { + elementResult += `${" ".repeat(depth + 1)}1..${subtests.plan.end}`; + if (subtests.plan.skip) { + const planReason = escapeSpecialChars(subtests.plan.skip.reason); + elementResult += ` # SKIP ${planReason}`; + } + elementResult += "\n"; + } + let subElemIdx = 0; + for (const nestedElement of subtests.body) { + elementResult += processElement( + nestedElement, + () => ++subElemIdx, + depth + 1, + ); } - elementResult += "\n"; - } - for (const nestedElement of element.body) { - elementResult += processElement(nestedElement, depth + 1); - } - if (element.diagnostic) { - elementResult += `\n${yamlBlock(element.diagnostic)}`; } + elementResult += "\n"; break; } case "comment": { @@ -266,9 +318,9 @@ export function stringify>( result += "\n"; } - // Process body elements + let index = 0; for (const element of tc.body) { - result += processElement(element, 0); + result += processElement(element, () => ++index, 0); } // Process footers diff --git a/lib/tap/mod_test.ts b/lib/tap/mod_test.ts index 30a23f4c..fcb6fbe5 100644 --- a/lib/tap/mod_test.ts +++ b/lib/tap/mod_test.ts @@ -1,13 +1,29 @@ -import { assertEquals, path, tapParser as tp } from "./deps-test.ts"; +import { assertEquals, path } from "./deps-test.ts"; import * as mod from "./mod.ts"; Deno.test("TAP content generator", async () => { - const fixture01 = (await new mod.TapContentBuilder() - .populate((body) => { - body - .comment("comment at top of file") - .ok("Input file opened") - .notOk("First line of the input valid", { + const fixture01 = (await mod.TapContentBuilder.create() + .populate(async (bb) => { + const { factory: f } = bb; + await bb.compose( + f.comment("comment at top of file"), + f.ok("Input file opened (with subtests)", { + subtests: async (sb) => { + await sb.ok("sub 1"); + await sb.notOk("sub 2", { + diagnostic: { + message: "sub 2 invalid", + severity: "fatal", + }, + }); + return { + body: sb.content, + title: "subtests", + plan: sb.plan(), + }; + }, + }), + f.notOk("First line of the input valid", { diagnostic: { message: "First line invalid", severity: "fail", @@ -16,26 +32,25 @@ Deno.test("TAP content generator", async () => { expect: "Fnible", }, }, - }) - .ok("Read the rest of the file") - .comment("comment 2") - .notOk("Summarized correctly", { - todo: "Not written yet", - diagnostic: { - message: "Can't make summary yet", - severity: "todo", - }, - }); + }), + ); + await bb.ok("Read the rest of the file"); + bb.comment("comment 2"); + await bb.notOk("Summarized correctly", { + todo: "Not written yet", + diagnostic: { + message: "Can't make summary yet", + severity: "todo", + }, + }); })) .tapContent(); - const tapOutput01 = mod.stringify(fixture01); - // now parse the output and see if it was parsed properly - const parsed01 = tp.Parser.parse(tapOutput01); + const tapOutput01 = mod.stringify(fixture01); assertEquals( await Deno.readTextFile( path.fromFileUrl(import.meta.resolve("./fixture-01.tap")), ), - tp.Parser.stringify(parsed01), + tapOutput01, ); });