Skip to content

Commit f5d625c

Browse files
committed
✨Add pls test command and initial test framework
Since we'll be writing more and more of PlatformScript in PlatformScript, we need a way to test PlatformScript in PlatformScript. This adds a `pls test` command that will find all files ending in the `.test.yaml` extension in the `test` directory, and runs them as PlatformScript tests. Added to stdlib is the "testing.yaml" module where you can find a test constructor, as well as matchers. The structure of a test is really, really simple for now. It is just a list of expectations: ```yaml $test: - expect: - subject - matcher - matcher - matcher - expect: - subject - matcher - matcher ``` Matchers are just functions that run on the subject and return a result for it. The following functions are shipped initially to create matchers: - `toEqual: expected`: takes an expected value and returns a matcher that passes when the subject is equal to the expected. - `not: matcher`: takes another matcher and passes only if that matcher fails. This is not the end all be all of testing frameworks, but what it _is_, is something that we can use to write tests for our PlatformScript as we begin to use it.
1 parent 46b514c commit f5d625c

File tree

9 files changed

+296
-8
lines changed

9 files changed

+296
-8
lines changed

Diff for: .github/workflows/verify.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ jobs:
4141
deno task test
4242
cd www && deno task test
4343
44+
- name: test stdlib
45+
run: |
46+
deno task pls test
47+
4448
- name: check-npm-build
4549
run: |
4650
cat version.json | xargs deno task build:npm

Diff for: builtin.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as data from "./data.ts";
2+
import { testing } from "./builtin/testing.ts";
3+
4+
export const builtin = data.map({
5+
"testing": testing,
6+
});

Diff for: builtin/testing.ts

+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { PSEnv, PSFnCallContext, PSList, PSValue } from "../types.ts";
2+
import type { Operation } from "../deps.ts";
3+
import { equal } from "../equal.ts";
4+
import * as data from "../data.ts";
5+
6+
export interface TestResult {
7+
type: "pass" | "fail";
8+
message: PSValue;
9+
}
10+
11+
export function isTestResult(value: unknown): value is TestResult {
12+
return !!value && typeof value === "object" &&
13+
["pass", "fail"].includes((value as TestResult).type);
14+
}
15+
16+
export const testing = data.map({
17+
"test": data.fn(function* (cxt) {
18+
return data.external(yield* test(cxt));
19+
}, { name: "definition" }),
20+
"toEqual": data.fn(function* (expected) {
21+
return data.fn(function* (actual) {
22+
if (equal(expected.arg, actual.arg).value) {
23+
return data.external({
24+
type: "pass",
25+
message: data.map({
26+
"equal to": expected.arg,
27+
}),
28+
});
29+
} else {
30+
return data.external({
31+
type: "fail",
32+
message: data.map({
33+
"equal to": expected.arg,
34+
}),
35+
});
36+
}
37+
}, { name: "actual" });
38+
}, { name: "expected" }),
39+
"not": data.fn(function* (matcher) {
40+
return data.fn(function* (actual) {
41+
if (matcher.arg.type === "fn") {
42+
let result = yield* actual.env.call(matcher.arg, actual.arg);
43+
if (result.type === "external" && result.value.type == "fail") {
44+
return data.external({
45+
type: "pass",
46+
message: data.map({
47+
"not": result.value.message,
48+
}),
49+
});
50+
} else {
51+
return data.external({
52+
type: "fail",
53+
message: data.map({
54+
"not": result.value.message,
55+
}),
56+
});
57+
}
58+
} else {
59+
return data.external({
60+
type: "fail",
61+
message: data.map({
62+
"not": actual.arg,
63+
}),
64+
});
65+
}
66+
}, { name: "actual" });
67+
}, { name: "matcher" }),
68+
});
69+
70+
function* test({ arg, env }: PSFnCallContext): Operation<TestResult[]> {
71+
if (arg.type !== "list") {
72+
return [yield* step(arg, env)];
73+
} else {
74+
let results: TestResult[] = [];
75+
for (let item of arg.value) {
76+
results.push(yield* step(item, env));
77+
}
78+
return results;
79+
}
80+
}
81+
82+
function* step(arg: PSValue, env: PSEnv): Operation<TestResult> {
83+
if (arg.type === "map") {
84+
for (let [key, value] of arg.value.entries()) {
85+
if (key.value === "expect") {
86+
if (value.type === "list") {
87+
let [first, ...rest] = value.value ?? data.string("null");
88+
let subject = yield* env.eval(first ?? data.string(""));
89+
let results: PSValue[] = [];
90+
let pass = true;
91+
let matchers = (yield* env.eval(data.list(rest))) as PSList;
92+
for (let matcher of matchers.value) {
93+
if (matcher.type === "fn") {
94+
let result = yield* env.call(matcher, subject);
95+
if (
96+
result.type === "external" && result.value &&
97+
result.value.type && result.value.message
98+
) {
99+
if (result.value.type === "fail") {
100+
pass = false;
101+
}
102+
results.push(result.value.message);
103+
} else {
104+
results.push(result);
105+
}
106+
} else {
107+
results.push(matcher);
108+
}
109+
}
110+
return {
111+
type: pass ? "pass" : "fail",
112+
message: data.list([
113+
first,
114+
...results,
115+
]),
116+
};
117+
} else {
118+
return {
119+
type: "pass",
120+
message: value,
121+
};
122+
}
123+
}
124+
}
125+
}
126+
return {
127+
type: "pass",
128+
message: arg,
129+
};
130+
}

Diff for: cli/pls.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { main } from "./main.ts";
22
import { dispatch } from "./router.ts";
33
import { PlsCommand } from "./pls-command.ts";
44
import { RunCommand } from "./run-command.ts";
5+
import { TestCommand } from "./test-command.ts";
56

67
await main(function* (args) {
78
yield* dispatch(["pls", ...args], {
89
"pls": [PlsCommand, {
910
"run :MODULE": RunCommand,
11+
"test :PATH": TestCommand,
1012
}],
1113
});
1214
});

Diff for: cli/test-command.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Route } from "./router.ts";
2+
import { walk } from "https://deno.land/[email protected]/fs/walk.ts";
3+
import { Operation, resolve, subscribe, Subscription } from "../deps.ts";
4+
import { load, map, print } from "../mod.ts";
5+
import type { TestResult } from "../builtin/testing.ts";
6+
import type { PSValue } from "../types.ts";
7+
8+
export const TestCommand: Route = {
9+
options: [],
10+
help: {
11+
HEAD: "Run a suite of PlatformScript tests",
12+
USAGE: "pls test PATH",
13+
},
14+
*handle({ segments }) {
15+
let path = segments.PATH ?? "test";
16+
17+
let pass = true;
18+
19+
let options = {
20+
match: [new RegExp(`^${path}`)],
21+
exts: [".test.yaml"],
22+
};
23+
24+
let foundSomeTests = false;
25+
26+
yield* forEach(subscribe(walk(".", options)), function* (entry) {
27+
foundSomeTests = true;
28+
if (entry.isFile) {
29+
console.log(`${entry.path}:`);
30+
31+
let location = new URL(`file://${resolve(entry.path)}`).toString();
32+
let mod = yield* load({ location });
33+
34+
if (mod.value.type !== "external") {
35+
throw new Error(
36+
`test file should return a test object see https://pls.pub/docs/testing.html for details`,
37+
);
38+
} else {
39+
let results: TestResult[] = mod.value.value;
40+
for (let result of results) {
41+
let message: PSValue;
42+
if (result.type === "fail") {
43+
pass = false;
44+
message = map({
45+
"❌": result.message,
46+
});
47+
} else {
48+
message = map({
49+
"✅": result.message,
50+
});
51+
}
52+
console.log(print(message).value);
53+
}
54+
}
55+
}
56+
});
57+
58+
if (!foundSomeTests) {
59+
throw new Error(`no tests found corresponding to ${path}`);
60+
}
61+
62+
if (!pass) {
63+
throw new Error("test failure");
64+
}
65+
},
66+
};
67+
68+
function* forEach<T, R>(
69+
subscription: Subscription<T, R>,
70+
block: (value: T) => Operation<void>,
71+
): Operation<R> {
72+
let next = yield* subscription;
73+
for (; !next.done; next = yield* subscription) {
74+
yield* block(next.value);
75+
}
76+
return next.value;
77+
}

Diff for: equal.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { PSBoolean, PSValue } from "./types.ts";
2+
import * as data from "./data.ts";
3+
4+
export function equal(a: PSValue, b: PSValue): PSBoolean {
5+
const t = data.boolean(true);
6+
const f = data.boolean(false);
7+
if (a.type === "map" && b.type === "map") {
8+
let _a = [...a.value.entries()];
9+
let _b = [...b.value.entries()];
10+
if (_a.length !== _b.length) {
11+
return t;
12+
}
13+
for (let i = 0; i < _a.length; i++) {
14+
let [a_key, a_val] = _a[i];
15+
let [b_key, b_val] = _b[i];
16+
if (!equal(a_key, b_key) || !equal(a_val, b_val)) {
17+
return f;
18+
}
19+
}
20+
return t;
21+
} else if (a.type === "list" && b.type === "list") {
22+
if (a.value.length !== b.value.length) {
23+
return f;
24+
}
25+
for (let i = 0; i < a.value.length; i++) {
26+
if (!equal(a.value[i], b.value[i])) {
27+
return f;
28+
}
29+
}
30+
return t;
31+
}
32+
return data.boolean(a.value === b.value);
33+
}

Diff for: load.ts

+16-8
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import type { PSEnv, PSMap, PSModule, PSValue } from "./types.ts";
22
import type { Operation } from "./deps.ts";
33

44
import { expect, useAbortSignal } from "./deps.ts";
5-
import { exclude, lookup } from "./psmap.ts";
5+
import { concat, exclude, lookup } from "./psmap.ts";
66
import { createYSEnv, parse } from "./evaluate.ts";
77
import { recognize } from "./recognize.ts";
8+
import { builtin } from "./builtin.ts";
89
import * as data from "./data.ts";
910

1011
export interface LoadOptions {
@@ -18,10 +19,17 @@ export function* load(options: LoadOptions): Operation<PSModule> {
1819
let { location, base, env, canon } = options;
1920
let url = typeof location === "string" ? new URL(location, base) : location;
2021

21-
let content = yield* read(url);
22-
let source = parse(content);
23-
24-
return yield* moduleEval({ source, location: url, env, canon });
22+
try {
23+
let content = yield* read(url);
24+
let source = parse(content);
25+
return yield* moduleEval({ source, location: url, env, canon });
26+
} catch (error) {
27+
if (error.name === "NotFound") {
28+
throw new Error(`module not found: ${location}`);
29+
} else {
30+
throw error;
31+
}
32+
}
2533
}
2634

2735
export interface ModuleEvalOptions {
@@ -55,7 +63,7 @@ export function* moduleEval(options: ModuleEvalOptions): Operation<PSModule> {
5563
if (imports.type === "just") {
5664
if (imports.value.type !== "map") {
5765
throw new Error(
58-
`imports must be specified as a mapping of names: URL but was ${imports.value.type}`,
66+
`imports must be specified as a mapping of names: URL but was '${imports.value.type}'`,
5967
);
6068
}
6169
for (let [names, loc] of imports.value.value.entries()) {
@@ -74,8 +82,8 @@ export function* moduleEval(options: ModuleEvalOptions): Operation<PSModule> {
7482
let dep = loc.value === "--canon--"
7583
? ({
7684
location: loc.value,
77-
source: canon,
78-
value: canon,
85+
source: concat(builtin, canon),
86+
value: concat(builtin, canon),
7987
imports: [],
8088
})
8189
: yield* load({

Diff for: stdlib/testing.yaml

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
$import:
2+
testing: --canon--
3+
4+
not: $testing.not
5+
test: $testing.test
6+
toEqual: $testing.toEqual

Diff for: test/stdlib/testing.test.yaml

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
$import:
2+
test, not, toEqual: ../../stdlib/testing.yaml
3+
4+
$test:
5+
- expect:
6+
- Hello %(World)
7+
- $toEqual: Hello World
8+
- expect:
9+
- {hello: world}
10+
- $toEqual: {hello: world}
11+
- expect:
12+
- [hello, world]
13+
- $toEqual: [hello, world]
14+
- expect:
15+
- hello
16+
- $not:
17+
$toEqual: world
18+
- expect:
19+
- hello
20+
- $not:
21+
$not:
22+
$toEqual: hello

0 commit comments

Comments
 (0)