git.iko.soy/rowtype-yoga/purescript-yoga-http-api
on feat/custom-type-errors help sign in
Add negative compile tests (8 cases verifying type-level errors)
cb61c73Mark
@@ -1,4 +1,5 @@
1.spago
1.spago
2output
2output
3+output-compile-fail
3node_modules
4node_modules
4.purs-repl
5.purs-repl
test-compile-fail/cases/BodyTypeMismatch.pursnew file+19
@@ -0,0 +1,19 @@
1+-- EXPECT: Could not match type
2+module Test.CompileFail.BodyTypeMismatch where
3+
4+import Prelude
5+import Data.Variant (Variant)
6+import Effect.Aff (Aff)
7+import Yoga.HTTP.API.Path (Path, Lit)
8+import Yoga.HTTP.API.Route.Handler (Request)
9+import Yoga.HTTP.API.Route.Method (GET)
10+import Yoga.HTTP.API.Route.Response (Response, respondNoHeaders)
11+import Yoga.HTTP.API.Route.Route (Route)
12+import Yoga.HTTP.API.Route.RouteHandler (Handler, mkHandler)
13+
14+-- Route has no body (defaults to NoBody/Unit), but handler uses body as String
15+type MyRoute = Route GET (Path (Lit "health")) (Request {}) (ok :: { body :: String })
16+
17+test :: Handler MyRoute
18+test = mkHandler \{ body } ->
19+ pure (respondNoHeaders @"ok" (body :: String))
test-compile-fail/cases/DuplicateCaptureNames.pursnew file+21
@@ -0,0 +1,21 @@
1+-- EXPECT: Could not match type
2+module Test.CompileFail.DuplicateCaptureNames where
3+
4+import Prelude
5+import Data.Maybe (Maybe)
6+import Data.Variant (Variant)
7+import Effect.Aff (Aff)
8+import Yoga.HTTP.API.Path (Path, Lit, Capture, type (/), type (:))
9+import Yoga.HTTP.API.Route.Encoding (NoBody)
10+import Yoga.HTTP.API.Route.Handler (Request)
11+import Yoga.HTTP.API.Route.Method (GET)
12+import Yoga.HTTP.API.Route.Response (Response, respondNoHeaders)
13+import Yoga.HTTP.API.Route.Route (Route)
14+import Yoga.HTTP.API.Route.RouteHandler (Handler, mkHandler)
15+
16+-- Two captures both named "id" with different types
17+type MyRoute = Route GET (Path (Capture "id" Int / Capture "id" String)) (Request {}) (ok :: { body :: String })
18+
19+test :: Handler MyRoute
20+test = mkHandler \{ path: { id } } ->
21+ pure (respondNoHeaders @"ok" id)
test-compile-fail/cases/HandlerSignatureMismatch.pursnew file+21
@@ -0,0 +1,21 @@
1+-- EXPECT: Could not match type
2+module Test.CompileFail.HandlerSignatureMismatch where
3+
4+import Prelude
5+import Data.Maybe (Maybe)
6+import Data.Variant (Variant)
7+import Effect.Aff (Aff)
8+import Yoga.HTTP.API.Path (Path, Lit, Capture, type (/), type (:))
9+import Yoga.HTTP.API.Route.Encoding (NoBody)
10+import Yoga.HTTP.API.Route.Handler (Request)
11+import Yoga.HTTP.API.Route.Method (GET)
12+import Yoga.HTTP.API.Route.Response (Response, respondNoHeaders)
13+import Yoga.HTTP.API.Route.Route (Route)
14+import Yoga.HTTP.API.Route.RouteHandler (Handler, mkHandler)
15+
16+type MyRoute = Route GET (Path (Lit "users" / Capture "id" Int)) (Request {}) (ok :: { body :: String })
17+
18+-- Handler takes { id :: String } but route has Capture "id" Int
19+test :: Handler MyRoute
20+test = mkHandler \{ path: { id } } ->
21+ pure (respondNoHeaders @"ok" (show (id :: String)))
test-compile-fail/cases/InvalidResponseRecordStructure.pursnew file+17
@@ -0,0 +1,17 @@
1+-- EXPECT: No type class instance was found
2+module Test.CompileFail.InvalidResponseRecordStructure where
3+
4+import Prelude
5+import Yoga.HTTP.API.Path (Path, Lit)
6+import Yoga.HTTP.API.Route.Handler (Request)
7+import Yoga.HTTP.API.Route.Method (GET)
8+import Yoga.HTTP.API.Route.Route (Route)
9+import Yoga.HTTP.API.Route.RouteHandler (Handler, mkHandler)
10+import Yoga.HTTP.API.Route.Response (respondNoHeaders)
11+
12+-- Response record has "status" field which is not "body" or "headers"
13+type MyRoute = Route GET (Path (Lit "health")) (Request {}) (ok :: { body :: String, status :: Int })
14+
15+test :: Handler MyRoute
16+test = mkHandler \_ ->
17+ pure (respondNoHeaders @"ok" "healthy")
test-compile-fail/cases/InvalidStatusCodeLabel.pursnew file+9
@@ -0,0 +1,9 @@
1+-- EXPECT: StatusCodeMap
2+module Test.CompileFail.InvalidStatusCodeLabel where
3+
4+import Type.Proxy (Proxy(..))
5+import Yoga.HTTP.API.Route.StatusCode (class StatusCodeMap, statusCodeFor, StatusCode)
6+
7+-- "banana" is not a valid HTTP status code label
8+test :: StatusCode
9+test = statusCodeFor (Proxy :: Proxy "banana")
test-compile-fail/cases/MissingRequiredQueryParam.pursnew file+20
@@ -0,0 +1,20 @@
1+-- EXPECT: Could not match kind
2+module Test.CompileFail.MissingRequiredQueryParam where
3+
4+import Prelude
5+import Data.Variant (Variant)
6+import Effect.Aff (Aff)
7+import Yoga.HTTP.API.Path (Path, Lit, QueryParams, type (:?))
8+import Yoga.HTTP.API.Route.Handler (Request)
9+import Yoga.HTTP.API.Route.Method (GET)
10+import Yoga.HTTP.API.Route.Response (Response, respondNoHeaders)
11+import Yoga.HTTP.API.Route.Route (Route)
12+import Yoga.HTTP.API.Route.RouteHandler (Handler, mkHandler)
13+
14+-- Query param "limit" is optional (no Required wrapper), so handler gets Maybe Int
15+-- But handler tries to use it as plain Int
16+type MyRoute = Route GET (Path (Lit "items") :? (limit :: Int)) (Request {}) (ok :: { body :: String })
17+
18+test :: Handler MyRoute
19+test = mkHandler \{ query: { limit } } ->
20+ pure (respondNoHeaders @"ok" (show (limit :: Int)))
test-compile-fail/cases/WrongCaptureType.pursnew file+12
@@ -0,0 +1,12 @@
1+-- EXPECT: Could not match type
2+module Test.CompileFail.WrongCaptureType where
3+
4+import Data.Maybe (Maybe)
5+import Type.Proxy (Proxy(..))
6+import Yoga.HTTP.API.Path (Path, Lit, Capture, type (/), type (:), class ParsePath, parsePath)
7+
8+-- Boolean has no ParseParam instance, so parsePath should fail
9+type MyPath = Path (Lit "users" / Capture "valid" Boolean)
10+
11+test :: Proxy MyPath -> String -> Maybe { valid :: Boolean }
12+test = parsePath
test-compile-fail/cases/WrongResponseVariantLabel.pursnew file+19
@@ -0,0 +1,19 @@
1+-- EXPECT: Could not match type
2+module Test.CompileFail.WrongResponseVariantLabel where
3+
4+import Prelude
5+import Data.Variant (Variant)
6+import Effect.Aff (Aff)
7+import Yoga.HTTP.API.Path (Path, Lit)
8+import Yoga.HTTP.API.Route.Handler (Request)
9+import Yoga.HTTP.API.Route.Method (GET)
10+import Yoga.HTTP.API.Route.Response (Response, respondNoHeaders)
11+import Yoga.HTTP.API.Route.Route (Route)
12+import Yoga.HTTP.API.Route.RouteHandler (Handler, mkHandler)
13+
14+-- Route only has "ok" response, no "notFound"
15+type MyRoute = Route GET (Path (Lit "health")) (Request {}) (ok :: { body :: String })
16+
17+test :: Handler MyRoute
18+test = mkHandler \_ ->
19+ pure (respondNoHeaders @"notFound" "oops")
test-compile-fail/compile-fail.test.jsnew file+76
@@ -0,0 +1,76 @@
1+import { describe, it, expect, beforeAll } from "vitest";
2+import { execSync } from "child_process";
3+import { readdirSync, readFileSync } from "fs";
4+import { join } from "path";
5+
6+const casesDir = join(__dirname, "cases");
7+const outputDir = "output-compile-fail";
8+
9+function getSourceGlobs() {
10+ const raw = execSync("npx spago sources", {
11+ encoding: "utf-8",
12+ cwd: join(__dirname, ".."),
13+ stdio: ["pipe", "pipe", "pipe"],
14+ });
15+ return raw
16+ .split("\n")
17+ .filter((l) => l.trim() && !l.startsWith("test/"))
18+ .map((g) => `"${g}"`)
19+ .join(" ");
20+}
21+
22+let sourceGlobs;
23+
24+beforeAll(() => {
25+ sourceGlobs = getSourceGlobs();
26+ try {
27+ execSync(
28+ `npx purs compile --output ${outputDir} ${sourceGlobs}`,
29+ {
30+ cwd: join(__dirname, ".."),
31+ encoding: "utf-8",
32+ stdio: ["pipe", "pipe", "pipe"],
33+ }
34+ );
35+ } catch {
36+ // Warming cache - ignore errors
37+ }
38+}, 120_000);
39+
40+const cases = readdirSync(casesDir)
41+ .filter((f) => f.endsWith(".purs"))
42+ .sort();
43+
44+describe("Compile-fail tests", () => {
45+ for (const file of cases) {
46+ const filePath = join(casesDir, file);
47+ const firstLine = readFileSync(filePath, "utf-8").split("\n")[0];
48+ const expectMatch = firstLine.match(/^-- EXPECT:\s*(.+)/);
49+ const expectedSubstring = expectMatch ? expectMatch[1].trim() : null;
50+ const testName = file.replace(".purs", "");
51+
52+ it(testName, () => {
53+ let output = "";
54+ let exitCode = 0;
55+ try {
56+ execSync(
57+ `npx purs compile --output ${outputDir} ${sourceGlobs} "${filePath}"`,
58+ {
59+ cwd: join(__dirname, ".."),
60+ encoding: "utf-8",
61+ stdio: ["pipe", "pipe", "pipe"],
62+ }
63+ );
64+ } catch (err) {
65+ exitCode = err.status;
66+ output = (err.stdout || "") + (err.stderr || "");
67+ }
68+
69+ expect(exitCode, `Expected ${file} to fail compilation but it succeeded`).not.toBe(0);
70+
71+ if (expectedSubstring) {
72+ expect(output, `Expected error to contain "${expectedSubstring}"`).toContain(expectedSubstring);
73+ }
74+ }, 30_000);
75+ }
76+});
vitest.config.js+4−1
@@ -2,7 +2,10 @@ import { defineConfig } from "vitest/config";
2
2
3export default defineConfig({
3export default defineConfig({
4 test: {
4 test: {
5+ include: [
5− include: ["output/Test.Operators/index.js"],
6+ "output/Test.Operators/index.js",
7+ "test-compile-fail/compile-fail.test.js",
8+ ],
6 globals: true,
9 globals: true,
7 environment: "jsdom",
10 environment: "jsdom",
8 },
11 },