diff --git a/Docs/04-advanced-features/web-servers.md b/Docs/04-advanced-features/web-servers.md index 231ce23c..41b1b97a 100644 --- a/Docs/04-advanced-features/web-servers.md +++ b/Docs/04-advanced-features/web-servers.md @@ -1044,6 +1044,28 @@ otherwise: end check ``` +## Worked example: Laravel starter app + +WFL can express the **public HTTP surface** of the official Laravel +application skeleton ([laravel/laravel](https://github.com/laravel/laravel) +13.x) — the starter app, not the framework. That skeleton is thin: `GET /` +(welcome), `GET /up` (health), `GET /robots.txt`, a 404 fallback, and +method-not-allowed on the registered paths. + +The port lives at [`examples/laravel-app/`](../../examples/laravel-app/README.md): + +```bash +wfl examples/laravel-app/app.wfl +``` + +Open `http://127.0.0.1:8000`. The welcome page, `/up`, and `/robots.txt` are +the same routes Laravel's skeleton exposes. Welcome-page copy and CSS are +original WFL; they are not a Blade or Tailwind reconstruction. + +WFL does **not** port Eloquent, Blade, Artisan, Vite, queues, mail, or +sessions. Those live in `laravel/framework` and have no equivalent here. The +example README's capability map lists every mapped route and every gap. + ## Testing Your Server ### With a Browser diff --git a/Docs/README.md b/Docs/README.md index 3c812e2c..41e9f9e7 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -541,6 +541,7 @@ Every code example is tested with the WFL compiler to ensure accuracy! - **[Installation](02-getting-started/installation.md)** - Get started in 5 minutes - **[Language Basics](03-language-basics/index.md)** - Core concepts - **[TestPrograms](../TestPrograms/)** - 90+ example programs +- **[Laravel starter-app port](../examples/laravel-app/README.md)** - WFL port of laravel/laravel 13.x (the app, not the framework) - **[Contributing](contributing/contributing-guide.md)** - Workflow + [root CONTRIBUTING.md](../CONTRIBUTING.md) ### Policy Documents diff --git a/Engineering/designs/2026-09-02-laravel-app-port.md b/Engineering/designs/2026-09-02-laravel-app-port.md new file mode 100644 index 00000000..d1dc612f --- /dev/null +++ b/Engineering/designs/2026-09-02-laravel-app-port.md @@ -0,0 +1,49 @@ +# Design: port laravel/laravel (the app) to WFL + +**Status:** Implemented as `examples/laravel-app/` +**Source:** [laravel/laravel](https://github.com/laravel/laravel) 13.x +**Risk class:** R2 (example + HTTP contract; no change to the concurrent +listen/respond/stream implementation — §11.3 stays on those primitives) + +## What “the actual app” is + +`laravel/laravel` is the application skeleton users get from `laravel new`. +It is not the framework. On 13.x the runnable HTTP surface is: + +1. `GET|HEAD /` → `resources/views/welcome.blade.php` +2. `GET|HEAD /up` → framework health registered in `bootstrap/app.php` +3. `GET|HEAD /robots.txt` → `User-agent: *` / `Disallow:` +4. Unknown paths → 404 +5. Other methods on those registered paths → 405 + +Plus unused-but-present scaffolding: empty `Controller`, `User` model, +`artisan inspire`, Vite/CSS/JS entrypoints, config, migrations. + +## Approach + +Port the **observable app**, not the framework. + +- Keep WFL idioms (`route`, `include from`, `listen`, containers). +- Extract routing into testable actions (`status_for_path`, `body_for_path`, + `content_type_for_path`) so `TestPrograms/` can assert without a socket, + and `tests/laravel_app_http_test.rs` can assert the real binary over HTTP. +- Write original welcome-page copy and CSS. Do not reconstruct Blade, + Tailwind, or Laravel’s logo/SVG. +- Document every gap in `examples/laravel-app/README.md`. A missing + framework feature is a documented gap, not a silent pretence. + +## Rejected alternatives + +1. **Reimplement Laravel in WFL** (service container, Eloquent, Blade, + Artisan). WFL does not have those primitives; claiming them would + violate the docs-honesty rule. +2. **Single-file demo that only prints “Hello”.** That is not a port of + the starter app’s public routes. +3. **Copy `welcome.blade.php` verbatim.** Copyrighted; also fights WFL’s + no-unlearning / original-example bar. + +## Residual gaps (by design) + +Eloquent, Blade, Vite, Artisan, queues, cache, mail, sessions, +broadcasting, service providers, and `.env` config beyond bind address. +See the capability map in the example README. diff --git a/Engineering/evidence/2026-09-02-laravel-app-port-green.md b/Engineering/evidence/2026-09-02-laravel-app-port-green.md new file mode 100644 index 00000000..eef48b24 --- /dev/null +++ b/Engineering/evidence/2026-09-02-laravel-app-port-green.md @@ -0,0 +1,19 @@ +# Green evidence — Laravel starter-app port + +**Date:** 2026-09-02 +**Risk class:** R2 — new example and HTTP contract. This change does **not** +modify `main loop concurrently:`, request handling, or streaming statements +(testing.md §11.3). Those primitives already have R3 coverage in +`tests/concurrent_*.rs` / `tests/http_*.rs`; this example only calls them. +**Red ancestor:** `06b3368` (`test: add failing Laravel starter-app port coverage`) + +## Layers + +| Layer | Command | Result | +|---|---|---| +| WFL feature tests | `./target/release/wfl --test TestPrograms/laravel_app/laravel_starter_app.test.wfl` | 16 passed, 0 failed, exit 0 | +| Rust HTTP e2e | `cargo test --test laravel_app_http_test` | 5 passed, 0 failed | +| Inspire CLI | `./target/release/wfl examples/laravel-app/inspire.wfl` | printed `Readability is a feature, not a luxury.` | +| Hygiene | `python3 scripts/check_repo_hygiene.py --mode static` | exit 0 | + +HTTP e2e (real `wfl` binary, real sockets): `GET /` 200, `GET /up` 200 + “Application up”, `GET /robots.txt` skeleton body, unknown path 404, `POST /` 405. diff --git a/Engineering/evidence/2026-09-02-laravel-app-port-red.md b/Engineering/evidence/2026-09-02-laravel-app-port-red.md new file mode 100644 index 00000000..c6a1af3e --- /dev/null +++ b/Engineering/evidence/2026-09-02-laravel-app-port-red.md @@ -0,0 +1,19 @@ +# Red evidence — Laravel starter-app port + +**Date:** 2026-09-02 +**Risk class:** R2 (new user-facing example + HTTP contract; no runtime/concurrency change) +**Command:** `./target/release/wfl --test TestPrograms/laravel_app/laravel_starter_app.test.wfl` +**Exit:** 1 +**Result:** 16 tests, 1 passed (`that true is true`), 15 failed for the intended reason. + +Stub router returns `418` / `"stub"` / `"application/octet-stream"` so the failures are assertion mismatches, not missing files or parse errors: + +- `GET /` / `HEAD /` / `GET /up` / `GET /robots.txt` expected 200, got 418 +- unknown path expected 404, got 418 +- `POST /` expected 405, got 418 +- welcome / health / robots / 404 / 405 bodies did not contain the required markers +- HTML and `text/plain` content types were `application/octet-stream` +- `User.display_name()` returned `""` instead of the stored name +- `inspire_quote` length was 0 + +This Red run is an ancestor of the Green implementation commit on `cursor/laravel-app-port-b108`. diff --git a/History/dev-diary/2026/2026-09-02-laravel-app-port.md b/History/dev-diary/2026/2026-09-02-laravel-app-port.md new file mode 100644 index 00000000..8f3ff77c --- /dev/null +++ b/History/dev-diary/2026/2026-09-02-laravel-app-port.md @@ -0,0 +1,37 @@ +# 2026-09-02 — Port of laravel/laravel (the starter app) to WFL + +## What this is + +A faithful-as-able port of the official Laravel **application skeleton** +([laravel/laravel](https://github.com/laravel/laravel) 13.x) — the repo you +get from `laravel new`, not `laravel/framework`. + +## What mapped + +The 13.x skeleton's public HTTP surface is small, and WFL can express it: + +- `GET /` welcome HTML (original copy and CSS; not a Blade reconstruction) +- `GET /up` health, the route `bootstrap/app.php` registers +- `GET /robots.txt` with the skeleton's two-line body +- unknown paths → 404 +- non-GET/HEAD on registered paths → 405 +- a `User` container for `app/Models/User.php`'s name + email fields +- `inspire.wfl` as the `artisan inspire` stand-in + +Routing is extracted into actions so +`TestPrograms/laravel_app/laravel_starter_app.test.wfl` can assert without a +socket, and `tests/laravel_app_http_test.rs` drives the real `wfl` binary +over HTTP. + +## What did not map + +Eloquent, Blade, Vite, Artisan, queues, cache, mail, sessions, +broadcasting, service providers, and most of `.env` / `config/*.php`. Those +are framework features. The example README's capability map lists them as +gaps rather than pretending they shipped. + +## Tests + +Red: stub router returned 418 / `"stub"`; 15 of 16 feature tests failed on +assertions (`Engineering/evidence/2026-09-02-laravel-app-port-red.md`). +Green: the same file, plus the HTTP e2e suite. diff --git a/TestPrograms/laravel_app/laravel_starter_app.test.wfl b/TestPrograms/laravel_app/laravel_starter_app.test.wfl new file mode 100644 index 00000000..742a7f23 --- /dev/null +++ b/TestPrograms/laravel_app/laravel_starter_app.test.wfl @@ -0,0 +1,111 @@ +// Feature + unit coverage for the WFL port of laravel/laravel (13.x starter app). +// Mirrors Laravel's tests/Feature/ExampleTest.php (GET / is 200) and +// tests/Unit/ExampleTest.php, and adds the other public routes the skeleton +// actually exposes: /up (bootstrap health), /robots.txt, 404, and 405. + +include from "../../examples/laravel-app/routes.wfl" +include from "../../examples/laravel-app/user.wfl" +include from "../../examples/laravel-app/quotes.wfl" + +describe "Example": + test "that true is true": + expect yes to be yes + end test +end describe + +describe "Laravel starter app routes": + test "GET / returns 200": + store reply_status as status_for_path of "/" and "GET" + expect reply_status to equal 200 + end test + + test "HEAD / returns 200": + store reply_status as status_for_path of "/" and "HEAD" + expect reply_status to equal 200 + end test + + test "GET /up returns 200": + store reply_status as status_for_path of "/up" and "GET" + expect reply_status to equal 200 + end test + + test "GET /robots.txt returns 200": + store reply_status as status_for_path of "/robots.txt" and "GET" + expect reply_status to equal 200 + end test + + test "GET /missing returns 404": + store reply_status as status_for_path of "/missing" and "GET" + expect reply_status to equal 404 + end test + + test "POST / returns 405": + store reply_status as status_for_path of "/" and "POST" + expect reply_status to equal 405 + end test +end describe + +describe "Laravel starter app responses": + test "welcome page identifies the WFL starter app": + store welcome_html as body_for_path of "/" and "GET" + expect welcome_html to contain "Your starter app is ready" + expect welcome_html to contain "WFL starter" + end test + + test "health page reports the application is up": + store health_html as body_for_path of "/up" and "GET" + expect health_html to contain "Application up" + end test + + test "robots.txt matches the Laravel skeleton": + store robots_body as body_for_path of "/robots.txt" and "GET" + expect robots_body to contain "User-agent: *" + expect robots_body to contain "Disallow:" + end test + + test "unknown path returns a Not Found body": + store missing_html as body_for_path of "/no-such-route" and "GET" + expect missing_html to contain "Not Found" + end test + + test "POST / returns a method-not-allowed body": + store not_allowed_body as body_for_path of "/" and "POST" + expect not_allowed_body to contain "Method Not Allowed" + end test + + test "HTML routes advertise an HTML content type": + store welcome_type as content_type_for_path of "/" + store health_type as content_type_for_path of "/up" + store missing_type as content_type_for_path of "/missing" + expect welcome_type to equal "text/html" + expect health_type to equal "text/html" + expect missing_type to equal "text/html" + end test + + test "robots.txt is served as text/plain": + store robots_type as content_type_for_path of "/robots.txt" + expect robots_type to equal "text/plain" + end test +end describe + +describe "Laravel starter app User model": + test "a User exposes name and email": + create new User as demo_user: + name is "Test User" + email is "test@example.com" + end + store person_name as demo_user.display_name() + store person_email as demo_user.email_address() + expect person_name to equal "Test User" + expect person_email to equal "test@example.com" + end test +end describe + +describe "Laravel starter app inspire command": + test "inspire_quote returns a non-empty quote": + store quote as inspire_quote + expect quote to be of type "Text" + store quote_length as length of quote + expect quote_length to be greater than 10 + end test +end describe diff --git a/examples/laravel-app/.wflcfg b/examples/laravel-app/.wflcfg new file mode 100644 index 00000000..22e7cf76 --- /dev/null +++ b/examples/laravel-app/.wflcfg @@ -0,0 +1,2 @@ +# Bind the starter app to loopback. Override to 0.0.0.0 to expose it. +web_server_bind_address = 127.0.0.1 diff --git a/examples/laravel-app/README.md b/examples/laravel-app/README.md new file mode 100644 index 00000000..740f8b38 --- /dev/null +++ b/examples/laravel-app/README.md @@ -0,0 +1,64 @@ +# WFL port of the Laravel application skeleton + +This directory is a WFL port of the official +[laravel/laravel](https://github.com/laravel/laravel) **starter app** (13.x), +not the Laravel framework. + +Laravel's application repo is intentionally thin: a welcome page, a health +endpoint, a `robots.txt`, an empty controller, a User model, and an `inspire` +console command. Everything else (Eloquent, Blade, Artisan, Vite, queues, +mail, sessions) lives in `laravel/framework` and is **not** claimed here. + +## Run + +```bash +wfl examples/laravel-app/app.wfl +``` + +Then open `http://127.0.0.1:8000`. + +```bash +wfl examples/laravel-app/inspire.wfl # artisan inspire equivalent +``` + +`.wflcfg` binds the server to `127.0.0.1`. Change `web_server_bind_address` to +`0.0.0.0` only when you intend to expose it. + +## Capability map + +| Laravel 13.x skeleton | WFL port | Status | +|---|---|---| +| `GET /` → `view('welcome')` | `welcome_page` HTML | Mapped (original copy/CSS) | +| `GET /up` health (`bootstrap/app.php`) | `/up` → 200 + “Application up” | Mapped | +| `public/robots.txt` | same two-line body | Mapped | +| Unknown path | 404 HTML | Mapped | +| `Route::get` (GET/HEAD only) | other methods → 405 | Mapped | +| `app/Models/User.php` | `User` container (`name`, `email`) | Partial — no Eloquent, casts, or auth | +| `artisan inspire` | `inspire.wfl` / `inspire_quote` | Partial — original quote, not the framework catalog | +| `app/Http/Controllers/Controller.php` | (empty in Laravel) | Nothing to port | +| `app/Providers/AppServiceProvider.php` | — | Not ported (no service container) | +| Blade / Vite / Tailwind | inline HTML in `views.wfl` | Not ported | +| Eloquent, migrations, factories, seeders | — | Not ported | +| Artisan, queues, cache, mail, sessions, broadcasting | — | Not ported | +| `.env` / `config/*.php` | `.wflcfg` bind address only | Partial | +| PHPUnit Feature/Unit example tests | `TestPrograms/laravel_app/laravel_starter_app.test.wfl` | Mapped + expanded | + +## Layout + +```text +app.wfl front controller (public/index.php + listen) +routes.wfl route table (routes/web.php + /up) +views.wfl welcome, health, 404, robots bodies +user.wfl User container +quotes.wfl inspire_quote +inspire.wfl console inspire command +public/robots.txt crawler policy +.wflcfg loopback bind +``` + +## Tests + +```bash +wfl --test TestPrograms/laravel_app/laravel_starter_app.test.wfl +cargo test --test laravel_app_http_test +``` diff --git a/examples/laravel-app/app.wfl b/examples/laravel-app/app.wfl new file mode 100644 index 00000000..f0107e6e --- /dev/null +++ b/examples/laravel-app/app.wfl @@ -0,0 +1,29 @@ +// Front controller for the WFL port of laravel/laravel 13.x. +// Mirrors public/index.php + bootstrap/app.php: listen, then dispatch routes. + +include from "routes.wfl" + +store listen_port as 8000 + +display "WFL starter (laravel/laravel port) on port " with listen_port +display "Routes: GET / , GET /up , GET /robots.txt" + +create map allow_get_head: + "Allow" is "GET, HEAD" +end map + +listen on port listen_port as laravel_app + +main loop concurrently: + wait for request comes in on laravel_app as incoming_request + store request_path as path of incoming_request + store request_method as method of incoming_request + store reply_status as status_for_path of request_path and request_method + store reply_body as body_for_path of request_path and request_method + store reply_type as content_type_for_path of request_path + check if reply_status is equal to 405: + respond to incoming_request with reply_body and status reply_status and content_type reply_type and headers allow_get_head + otherwise: + respond to incoming_request with reply_body and status reply_status and content_type reply_type + end check +end loop diff --git a/examples/laravel-app/inspire.wfl b/examples/laravel-app/inspire.wfl new file mode 100644 index 00000000..03cd11b9 --- /dev/null +++ b/examples/laravel-app/inspire.wfl @@ -0,0 +1,4 @@ +// Equivalent of `php artisan inspire` from routes/console.php. +include from "quotes.wfl" +store quote as inspire_quote +display quote diff --git a/examples/laravel-app/public/robots.txt b/examples/laravel-app/public/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/examples/laravel-app/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/examples/laravel-app/quotes.wfl b/examples/laravel-app/quotes.wfl new file mode 100644 index 00000000..a244c92c --- /dev/null +++ b/examples/laravel-app/quotes.wfl @@ -0,0 +1,6 @@ +// Stand-in for Laravel's `artisan inspire` command. The quote list is original +// WFL text, not the framework's Inspiring::quote() catalog. + +define action called inspire_quote: + return "Readability is a feature, not a luxury." +end action diff --git a/examples/laravel-app/routes.wfl b/examples/laravel-app/routes.wfl new file mode 100644 index 00000000..45b01342 --- /dev/null +++ b/examples/laravel-app/routes.wfl @@ -0,0 +1,52 @@ +include from "views.wfl" + +// Public routes of laravel/laravel 13.x: +// GET|HEAD / welcome +// GET|HEAD /up health (bootstrap/app.php) +// GET|HEAD /robots.txt public/robots.txt +// other methods on those paths → 405 +// unknown path → 404 + +define action called status_for_path with parameters request_path and request_method: + route request_path: + when "/" or "/up" or "/robots.txt": + route request_method: + when "GET" or "HEAD": + return 200 + otherwise: + return 405 + end route + otherwise: + return 404 + end route +end action + +define action called body_for_path with parameters request_path and request_method: + route request_path: + when "/" or "/up" or "/robots.txt": + route request_method: + when "GET" or "HEAD": + route request_path: + when "/": + return welcome_page + when "/up": + return health_page + when "/robots.txt": + return robots_text + end route + otherwise: + return method_not_allowed_page + end route + otherwise: + return not_found_page + end route +end action + +define action called content_type_for_path with parameters request_path: + route request_path: + when "/robots.txt": + return "text/plain" + otherwise: + return "text/html" + end route +end action diff --git a/examples/laravel-app/user.wfl b/examples/laravel-app/user.wfl new file mode 100644 index 00000000..bf3d0bdd --- /dev/null +++ b/examples/laravel-app/user.wfl @@ -0,0 +1,15 @@ +// Stand-in for app/Models/User.php. Eloquent, hashing, and auth are not +// ported; this container keeps the skeleton's name + email fields. + +create container User: + property name: Text + property email: Text + + action display_name: Text + return name + end + + action email_address: Text + return email + end +end diff --git a/examples/laravel-app/views.wfl b/examples/laravel-app/views.wfl new file mode 100644 index 00000000..05c5925b --- /dev/null +++ b/examples/laravel-app/views.wfl @@ -0,0 +1,90 @@ +// Page bodies for the WFL port of the laravel/laravel 13.x starter app. +// Copy and styling are original; they are not a Blade/Tailwind reconstruction. + +define action called welcome_page: + return " + + + + + WFL starter + + + +
+

WFL starter

+

Your starter app is ready

+

This program is a WFL port of the Laravel application skeleton. It serves the same public routes the 13.x starter app exposes, using WFL's built-in web server.

+ +

Read the capability map in examples/laravel-app/README.md for what mapped and what WFL cannot express (Eloquent, Blade, Artisan, queues).

+ +
+ +" +end action + +define action called health_page: + return " + + + + WFL starter + + +

Application up

+ +" +end action + +define action called not_found_page: + return " + + + + Not Found + + +

Not Found

+

No route matches this path.

+ +" +end action + +define action called robots_text: + return "User-agent: *\nDisallow:\n" +end action + +define action called method_not_allowed_page: + return "Method Not Allowed" +end action diff --git a/examples/web/README.md b/examples/web/README.md index 7a111d11..81c0ce08 100644 --- a/examples/web/README.md +++ b/examples/web/README.md @@ -8,6 +8,10 @@ Validated WFL web-server programs. endpoints (`/`, `/api/posts`, `/api/posts/1`), a 404 fallback, and a request cap so it terminates after 20 requests. +A larger worked example — a WFL port of the Laravel 13.x **application +skeleton** (welcome, `/up`, `robots.txt`, 404/405) — lives at +[`examples/laravel-app/`](../laravel-app/README.md). + Run one with `wfl examples/web/html_server.wfl` and open the printed URL. Examples are non-gating; the asserted web-server coverage lives in `TestPrograms/` and the web test scripts. diff --git a/tests/laravel_app_http_test.rs b/tests/laravel_app_http_test.rs new file mode 100644 index 00000000..4c6d9c5f --- /dev/null +++ b/tests/laravel_app_http_test.rs @@ -0,0 +1,186 @@ +//! End-to-end HTTP coverage for the WFL port of laravel/laravel (13.x). +//! +//! Spawns the real `wfl` binary on `examples/laravel-app/app.wfl` (copied to a +//! temp dir so the listen port can be a free OS port) and drives `/`, `/up`, +//! `/robots.txt`, an unknown path, `HEAD /`, and `POST /` over a real socket. + +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; + +mod common; + +fn example_app_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/laravel-app") +} + +fn stage_app(port: u16) -> tempfile::TempDir { + let src = example_app_dir(); + let dir = tempfile::tempdir().expect("tempdir for laravel-app port"); + for name in [ + "app.wfl", + "routes.wfl", + "views.wfl", + "user.wfl", + "quotes.wfl", + ] { + let content = std::fs::read_to_string(src.join(name)) + .unwrap_or_else(|e| panic!("read {}: {e}", src.join(name).display())); + let content = content.replace( + "store listen_port as 8000", + &format!("store listen_port as {port}"), + ); + std::fs::write(dir.path().join(name), content).expect("write staged app file"); + } + let public = dir.path().join("public"); + std::fs::create_dir_all(&public).expect("create public/"); + std::fs::copy(src.join("public/robots.txt"), public.join("robots.txt")) + .expect("copy robots.txt"); + if src.join(".wflcfg").exists() { + std::fs::copy(src.join(".wflcfg"), dir.path().join(".wflcfg")).expect("copy .wflcfg"); + } + dir +} + +async fn start_app(port: u16) -> (Child, tempfile::TempDir) { + let dir = stage_app(port); + let program = dir.path().join("app.wfl"); + let mut child = Command::new(common::wfl_exe()) + .arg(&program) + .current_dir(dir.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .expect("spawn wfl laravel-app"); + + let stdout = child.stdout.take().expect("child stdout"); + let mut lines = BufReader::new(stdout).lines(); + tokio::time::timeout(Duration::from_secs(60), async { + while let Ok(Some(line)) = lines.next_line().await { + if line.contains("Server is listening on port") { + return; + } + } + panic!("laravel-app server exited before announcing its listen port"); + }) + .await + .expect("timed out waiting for laravel-app to listen"); + + (child, dir) +} + +async fn get(port: u16, path: &str) -> reqwest::Response { + reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}{path}")) + .send() + .await + .unwrap_or_else(|e| panic!("GET {path}: {e}")) +} + +async fn post(port: u16, path: &str) -> reqwest::Response { + reqwest::Client::new() + .post(format!("http://127.0.0.1:{port}{path}")) + .send() + .await + .unwrap_or_else(|e| panic!("POST {path}: {e}")) +} + +async fn head(port: u16, path: &str) -> reqwest::Response { + reqwest::Client::new() + .head(format!("http://127.0.0.1:{port}{path}")) + .send() + .await + .unwrap_or_else(|e| panic!("HEAD {path}: {e}")) +} + +#[tokio::test] +async fn welcome_page_returns_200_html() { + let port = common::free_tcp_port(); + let (_child, _dir) = start_app(port).await; + + let response = get(port, "/").await; + assert_eq!(response.status(), 200); + let body = response.text().await.expect("welcome body"); + assert!( + body.contains("Your starter app is ready"), + "welcome body should identify the starter app, got: {body}" + ); + assert!( + body.contains("WFL starter"), + "welcome body should name the WFL starter, got: {body}" + ); +} + +#[tokio::test] +async fn health_endpoint_reports_application_up() { + let port = common::free_tcp_port(); + let (_child, _dir) = start_app(port).await; + + let response = get(port, "/up").await; + assert_eq!(response.status(), 200); + let body = response.text().await.expect("health body"); + assert!( + body.contains("Application up"), + "health body should report Application up, got: {body}" + ); +} + +#[tokio::test] +async fn robots_txt_matches_laravel_skeleton() { + let port = common::free_tcp_port(); + let (_child, _dir) = start_app(port).await; + + let response = get(port, "/robots.txt").await; + assert_eq!(response.status(), 200); + let body = response.text().await.expect("robots body"); + assert!(body.contains("User-agent: *"), "got: {body}"); + assert!(body.contains("Disallow:"), "got: {body}"); +} + +#[tokio::test] +async fn unknown_path_returns_404() { + let port = common::free_tcp_port(); + let (_child, _dir) = start_app(port).await; + + let response = get(port, "/no-such-route").await; + assert_eq!(response.status(), 404); + let body = response.text().await.expect("404 body"); + assert!(body.contains("Not Found"), "got: {body}"); +} + +#[tokio::test] +async fn post_to_welcome_returns_405() { + let port = common::free_tcp_port(); + let (_child, _dir) = start_app(port).await; + + let response = post(port, "/").await; + assert_eq!(response.status(), 405); + let allow = response + .headers() + .get("allow") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + allow, "GET, HEAD", + "405 must advertise Allow: GET, HEAD, got {allow:?}" + ); + let body = response.text().await.expect("405 body"); + assert!(body.contains("Method Not Allowed"), "got: {body}"); +} + +#[tokio::test] +async fn head_welcome_returns_200_without_a_body() { + let port = common::free_tcp_port(); + let (_child, _dir) = start_app(port).await; + + let response = head(port, "/").await; + assert_eq!(response.status(), 200); + let body = response.text().await.expect("HEAD body"); + assert!( + body.is_empty(), + "HEAD / must transfer an empty body, got {body:?}" + ); +}