Skip to content

✨Add pls test command and initial test framework #83

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/verify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ jobs:
deno task test
cd www && deno task test

- name: test stdlib
run: |
deno task pls test

- name: check-npm-build
run: |
cat version.json | xargs deno task build:npm
6 changes: 6 additions & 0 deletions builtin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as data from "./data.ts";
import { testing } from "./builtin/testing.ts";

export const builtin = data.map({
"testing": testing,
});
130 changes: 130 additions & 0 deletions builtin/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { PSEnv, PSFnCallContext, PSList, PSValue } from "../types.ts";
import type { Operation } from "../deps.ts";
import { equal } from "../equal.ts";
import * as data from "../data.ts";

export interface TestResult {
type: "pass" | "fail";
message: PSValue;
}

export function isTestResult(value: unknown): value is TestResult {
return !!value && typeof value === "object" &&
["pass", "fail"].includes((value as TestResult).type);
}

export const testing = data.map({
"test": data.fn(function* (cxt) {
return data.external(yield* test(cxt));
}, { name: "definition" }),
"toEqual": data.fn(function* (expected) {
return data.fn(function* (actual) {
if (equal(expected.arg, actual.arg).value) {
return data.external({
type: "pass",
message: data.map({
"equal to": expected.arg,
}),
});
} else {
return data.external({
type: "fail",
message: data.map({
"equal to": expected.arg,
}),
});
}
}, { name: "actual" });
}, { name: "expected" }),
"not": data.fn(function* (matcher) {
return data.fn(function* (actual) {
if (matcher.arg.type === "fn") {
let result = yield* actual.env.call(matcher.arg, actual.arg);
if (result.type === "external" && result.value.type == "fail") {
return data.external({
type: "pass",
message: data.map({
"not": result.value.message,
}),
});
} else {
return data.external({
type: "fail",
message: data.map({
"not": result.value.message,
}),
});
}
} else {
return data.external({
type: "fail",
message: data.map({
"not": actual.arg,
}),
});
}
}, { name: "actual" });
}, { name: "matcher" }),
});

function* test({ arg, env }: PSFnCallContext): Operation<TestResult[]> {
if (arg.type !== "list") {
return [yield* step(arg, env)];
} else {
let results: TestResult[] = [];
for (let item of arg.value) {
results.push(yield* step(item, env));
}
return results;
}
}

function* step(arg: PSValue, env: PSEnv): Operation<TestResult> {
if (arg.type === "map") {
for (let [key, value] of arg.value.entries()) {
if (key.value === "expect") {
if (value.type === "list") {
let [first, ...rest] = value.value ?? data.string("null");
let subject = yield* env.eval(first ?? data.string(""));
let results: PSValue[] = [];
let pass = true;
let matchers = (yield* env.eval(data.list(rest))) as PSList;
for (let matcher of matchers.value) {
if (matcher.type === "fn") {
let result = yield* env.call(matcher, subject);
if (
result.type === "external" && result.value &&
result.value.type && result.value.message
) {
if (result.value.type === "fail") {
pass = false;
}
results.push(result.value.message);
} else {
results.push(result);
}
} else {
results.push(matcher);
}
}
return {
type: pass ? "pass" : "fail",
message: data.list([
first,
...results,
]),
};
} else {
return {
type: "pass",
message: value,
};
}
}
}
}
return {
type: "pass",
message: arg,
};
}
2 changes: 2 additions & 0 deletions cli/pls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { main } from "./main.ts";
import { dispatch } from "./router.ts";
import { PlsCommand } from "./pls-command.ts";
import { RunCommand } from "./run-command.ts";
import { TestCommand } from "./test-command.ts";

await main(function* (args) {
yield* dispatch(["pls", ...args], {
"pls": [PlsCommand, {
"run :MODULE": RunCommand,
"test :PATH": TestCommand,
}],
});
});
77 changes: 77 additions & 0 deletions cli/test-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type { Route } from "./router.ts";
import { walk } from "https://deno.land/std@0.177.0/fs/walk.ts";
import { Operation, resolve, subscribe, Subscription } from "../deps.ts";
import { load, map, print } from "../mod.ts";
import type { TestResult } from "../builtin/testing.ts";
import type { PSValue } from "../types.ts";

export const TestCommand: Route = {
options: [],
help: {
HEAD: "Run a suite of PlatformScript tests",
USAGE: "pls test PATH",
},
*handle({ segments }) {
let path = segments.PATH ?? "test";

let pass = true;

let options = {
match: [new RegExp(`^${path}`)],
exts: [".test.yaml"],
};

let foundSomeTests = false;

yield* forEach(subscribe(walk(".", options)), function* (entry) {
foundSomeTests = true;
if (entry.isFile) {
console.log(`${entry.path}:`);

let location = new URL(`file://${resolve(entry.path)}`).toString();
let mod = yield* load({ location });

if (mod.value.type !== "external") {
throw new Error(
`test file should return a test object see https://pls.pub/docs/testing.html for details`,
);
} else {
let results: TestResult[] = mod.value.value;
for (let result of results) {
let message: PSValue;
if (result.type === "fail") {
pass = false;
message = map({
"❌": result.message,
});
} else {
message = map({
"✅": result.message,
});
}
console.log(print(message).value);
}
}
}
});

if (!foundSomeTests) {
throw new Error(`no tests found corresponding to ${path}`);
}

if (!pass) {
throw new Error("test failure");
}
},
};

function* forEach<T, R>(
subscription: Subscription<T, R>,
block: (value: T) => Operation<void>,
): Operation<R> {
let next = yield* subscription;
for (; !next.done; next = yield* subscription) {
yield* block(next.value);
}
return next.value;
}
33 changes: 33 additions & 0 deletions equal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { PSBoolean, PSValue } from "./types.ts";
import * as data from "./data.ts";

export function equal(a: PSValue, b: PSValue): PSBoolean {
const t = data.boolean(true);
const f = data.boolean(false);
if (a.type === "map" && b.type === "map") {
let _a = [...a.value.entries()];
let _b = [...b.value.entries()];
if (_a.length !== _b.length) {
return t;
}
for (let i = 0; i < _a.length; i++) {
let [a_key, a_val] = _a[i];
let [b_key, b_val] = _b[i];
if (!equal(a_key, b_key) || !equal(a_val, b_val)) {
return f;
}
}
return t;
} else if (a.type === "list" && b.type === "list") {
if (a.value.length !== b.value.length) {
return f;
}
for (let i = 0; i < a.value.length; i++) {
if (!equal(a.value[i], b.value[i])) {
return f;
}
}
return t;
}
return data.boolean(a.value === b.value);
}
24 changes: 16 additions & 8 deletions load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { PSEnv, PSMap, PSModule, PSValue } from "./types.ts";
import type { Operation } from "./deps.ts";

import { expect, useAbortSignal } from "./deps.ts";
import { exclude, lookup } from "./psmap.ts";
import { concat, exclude, lookup } from "./psmap.ts";
import { createYSEnv, parse } from "./evaluate.ts";
import { recognize } from "./recognize.ts";
import { builtin } from "./builtin.ts";
import * as data from "./data.ts";

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

let content = yield* read(url);
let source = parse(content);

return yield* moduleEval({ source, location: url, env, canon });
try {
let content = yield* read(url);
let source = parse(content);
return yield* moduleEval({ source, location: url, env, canon });
} catch (error) {
if (error.name === "NotFound") {
throw new Error(`module not found: ${location}`);
} else {
throw error;
}
}
}

export interface ModuleEvalOptions {
Expand Down Expand Up @@ -55,7 +63,7 @@ export function* moduleEval(options: ModuleEvalOptions): Operation<PSModule> {
if (imports.type === "just") {
if (imports.value.type !== "map") {
throw new Error(
`imports must be specified as a mapping of names: URL but was ${imports.value.type}`,
`imports must be specified as a mapping of names: URL but was '${imports.value.type}'`,
);
}
for (let [names, loc] of imports.value.value.entries()) {
Expand All @@ -74,8 +82,8 @@ export function* moduleEval(options: ModuleEvalOptions): Operation<PSModule> {
let dep = loc.value === "--canon--"
? ({
location: loc.value,
source: canon,
value: canon,
source: concat(builtin, canon),
value: concat(builtin, canon),
imports: [],
})
: yield* load({
Expand Down
6 changes: 6 additions & 0 deletions stdlib/testing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
$import:
testing: --canon--

not: $testing.not
test: $testing.test
toEqual: $testing.toEqual
22 changes: 22 additions & 0 deletions test/stdlib/testing.test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
$import:
test, not, toEqual: ../../stdlib/testing.yaml

$test:
- expect:
- Hello %(World)
- $toEqual: Hello World
- expect:
- {hello: world}
- $toEqual: {hello: world}
- expect:
- [hello, world]
- $toEqual: [hello, world]
- expect:
- hello
- $not:
$toEqual: world
- expect:
- hello
- $not:
$not:
$toEqual: hello