| 1 | //! `g1t-runner deploy` and `g1t-runner detect` as a workflow step runs |
| 2 | //! them: in a checkout of its own, with nothing cloned, reporting to an API |
| 3 | //! (here a fake one on localhost) with the build's token. |
| 4 | |
| 5 | use std::io::{BufRead, BufReader, Read, Write}; |
| 6 | use std::net::TcpListener; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use std::process::Command; |
| 9 | use std::sync::{Arc, Mutex}; |
| 10 | |
| 11 | use serde_json::{Value, json}; |
| 12 | |
| 13 | fn runner() -> Command { |
| 14 | Command::new(env!("CARGO_BIN_EXE_g1t-runner")) |
| 15 | } |
| 16 | |
| 17 | /// A fresh checkout holding `files`. |
| 18 | fn checkout(name: &str, files: &[(&str, &str)]) -> PathBuf { |
| 19 | let dir = std::env::temp_dir().join(format!("g1t-runner-cli-{name}-{}", std::process::id())); |
| 20 | let _ = std::fs::remove_dir_all(&dir); |
| 21 | std::fs::create_dir_all(&dir).unwrap(); |
| 22 | for (path, text) in files { |
| 23 | let path = dir.join(path); |
| 24 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
| 25 | std::fs::write(path, text).unwrap(); |
| 26 | } |
| 27 | dir |
| 28 | } |
| 29 | |
| 30 | #[test] |
| 31 | fn detect_prints_what_the_build_would_find() { |
| 32 | let dir = checkout("detect", &[("site/index.html", "<h1>hello</h1>")]); |
| 33 | let output = runner().arg("detect").args(["--root-dir", "site"]).current_dir(&dir).env_remove("GIT_REMOTE").output().unwrap(); |
| 34 | assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); |
| 35 | assert_eq!(String::from_utf8_lossy(&output.stdout), "kind=html\ninstall=\nbuild=\noutput-dir=.\n"); |
| 36 | let unknown = runner().args(["detect", "--nope"]).current_dir(&dir).output().unwrap(); |
| 37 | assert_eq!(unknown.status.code(), Some(2)); |
| 38 | } |
| 39 | |
| 40 | /// The API's `/deployments/jobs/<id>/<step>`: answers each step as the |
| 41 | /// deployments service does, and keeps what it was sent. |
| 42 | fn fake_api() -> (String, Arc<Mutex<Vec<(String, Value)>>>) { |
| 43 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
| 44 | let base = format!("http://{}", listener.local_addr().unwrap()); |
| 45 | let seen = Arc::new(Mutex::new(Vec::new())); |
| 46 | let kept = seen.clone(); |
| 47 | std::thread::spawn(move || { |
| 48 | for stream in listener.incoming() { |
| 49 | let Ok(mut stream) = stream else { continue }; |
| 50 | let mut reader = BufReader::new(stream.try_clone().unwrap()); |
| 51 | let mut first = String::new(); |
| 52 | reader.read_line(&mut first).unwrap(); |
| 53 | let path = first.split_whitespace().nth(1).unwrap_or_default().to_owned(); |
| 54 | let mut length = 0; |
| 55 | loop { |
| 56 | let mut line = String::new(); |
| 57 | reader.read_line(&mut line).unwrap(); |
| 58 | if line.trim().is_empty() { |
| 59 | break; |
| 60 | } |
| 61 | if let Some((name, value)) = line.split_once(':') |
| 62 | && name.eq_ignore_ascii_case("content-length") |
| 63 | { |
| 64 | length = value.trim().parse().unwrap_or(0); |
| 65 | } |
| 66 | } |
| 67 | let mut body = vec![0; length]; |
| 68 | reader.read_exact(&mut body).unwrap(); |
| 69 | let body: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); |
| 70 | let answer = if path.ends_with("/session") { |
| 71 | json!({ "ok": true, "value": { "jwt": "complete-me", "buckets": [], "upload_url": "http://127.0.0.1:9/never" } }) |
| 72 | } else if path.ends_with("/registry") { |
| 73 | // As the deployments service answers while it runs no containers. |
| 74 | json!({ "ok": false, "error": { "code": "not_enabled", "message": "Docker deployments are not on." } }) |
| 75 | } else { |
| 76 | json!({ "ok": true, "value": true }) |
| 77 | }; |
| 78 | kept.lock().unwrap().push((path, body)); |
| 79 | let text = answer.to_string(); |
| 80 | let _ = write!( |
| 81 | stream, |
| 82 | "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}", |
| 83 | text.len() |
| 84 | ); |
| 85 | } |
| 86 | }); |
| 87 | (base, seen) |
| 88 | } |
| 89 | |
| 90 | fn deploy(dir: &Path, api: &str, extra: &[(&str, &str)]) -> std::process::Output { |
| 91 | let mut command = runner(); |
| 92 | command |
| 93 | .arg("deploy") |
| 94 | .current_dir(dir) |
| 95 | .env_remove("GIT_REMOTE") |
| 96 | .env("G1T_API", api) |
| 97 | .env("DEPLOY_ID", "dpl_1") |
| 98 | .env("DEPLOY_TOKEN", "build-token-0123456789") |
| 99 | .env("BUILD_SECRETS", r#"{"API_KEY":"very-secret-value"}"#) |
| 100 | .env("BUILD_ENV", r#"{"PUBLIC_URL":"https://example.com"}"#); |
| 101 | for (name, value) in extra { |
| 102 | command.env(name, value); |
| 103 | } |
| 104 | command.output().unwrap() |
| 105 | } |
| 106 | |
| 107 | #[test] |
| 108 | fn deploy_builds_the_checkout_it_runs_in_without_cloning() { |
| 109 | let (api, seen) = fake_api(); |
| 110 | let dir = checkout("deploy", &[("site/index.html", "<h1>hello</h1>"), ("site/about.html", "<p>about</p>"), ("README.md", "not served")]); |
| 111 | let output = deploy(&dir, &api, &[("ROOT_DIR", "site")]); |
| 112 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 113 | assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); |
| 114 | // Its log is the step's too. |
| 115 | assert!(stdout.contains("as it is checked out"), "{stdout}"); |
| 116 | assert!(stdout.contains("Uploading 2 files."), "{stdout}"); |
| 117 | let seen = seen.lock().unwrap(); |
| 118 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); |
| 119 | assert_eq!(steps, ["/deployments/jobs/dpl_1/started", "/deployments/jobs/dpl_1/session", "/deployments/jobs/dpl_1/finish"]); |
| 120 | for (_, body) in seen.iter() { |
| 121 | assert_eq!(body["token"], "build-token-0123456789"); |
| 122 | } |
| 123 | let manifest = seen[1].1["manifest"].as_object().unwrap(); |
| 124 | let mut files: Vec<&String> = manifest.keys().collect(); |
| 125 | files.sort(); |
| 126 | assert_eq!(files, ["/about.html", "/index.html"]); |
| 127 | let finish = &seen[2].1; |
| 128 | assert_eq!(finish["detected"], "html"); |
| 129 | assert_eq!(finish["completionJwt"], "complete-me"); |
| 130 | assert!(!finish["log"].as_str().unwrap().contains("build-token-0123456789")); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn a_failed_build_is_reported_and_fails_the_step() { |
| 135 | let (api, seen) = fake_api(); |
| 136 | let dir = checkout("fail", &[("README.md", "nothing to serve")]); |
| 137 | let output = deploy(&dir, &api, &[]); |
| 138 | assert_eq!(output.status.code(), Some(1)); |
| 139 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 140 | assert!(stdout.contains("found nothing to serve"), "{stdout}"); |
| 141 | let seen = seen.lock().unwrap(); |
| 142 | assert_eq!(seen.last().unwrap().0, "/deployments/jobs/dpl_1/fail"); |
| 143 | // A missing root directory says so. |
| 144 | drop(seen); |
| 145 | let missing = deploy(&dir, &api, &[("ROOT_DIR", "apps/web")]); |
| 146 | assert_eq!(missing.status.code(), Some(1)); |
| 147 | assert!(String::from_utf8_lossy(&missing.stdout).contains("does not exist in this checkout")); |
| 148 | } |
| 149 | |
| 150 | #[test] |
| 151 | fn deploy_needs_its_build() { |
| 152 | let output = runner().arg("deploy").env_remove("DEPLOY_ID").env_remove("DEPLOY_TOKEN").output().unwrap(); |
| 153 | assert_eq!(output.status.code(), Some(2)); |
| 154 | assert!(String::from_utf8_lossy(&output.stderr).contains("DEPLOY_ID")); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn a_container_is_built_as_its_site_where_containers_are_switched_off() { |
| 159 | let (api, seen) = fake_api(); |
| 160 | // A Go server beside a folder of files: a container, else the files. |
| 161 | let dir = checkout("container-site", &[("go.mod", "module x\n"), ("main.go", "package main\n"), ("public/index.html", "<h1>hello</h1>")]); |
| 162 | let output = deploy(&dir, &api, &[]); |
| 163 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 164 | assert!(output.status.success(), "{stdout} |
| 165 | {}", String::from_utf8_lossy(&output.stderr)); |
| 166 | assert!(stdout.contains("Building it as a Go server, not as a site of `public`"), "{stdout}"); |
| 167 | assert!(stdout.contains("as a static site instead"), "{stdout}"); |
| 168 | let seen = seen.lock().unwrap(); |
| 169 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); |
| 170 | assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry"); |
| 171 | assert_eq!(seen.last().unwrap().1["detected"], "static"); |
| 172 | drop(seen); |
| 173 | // A site beside a Dockerfile that runs a server: the site, with no |
| 174 | // image asked for, saying the Dockerfile is not used. |
| 175 | let (api, seen) = fake_api(); |
| 176 | let files = checkout( |
| 177 | "site-and-dockerfile", |
| 178 | &[("index.html", "<h1>hello</h1>"), ("server.js", ""), ("Dockerfile", "FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n")], |
| 179 | ); |
| 180 | let output = deploy(&files, &api, &[]); |
| 181 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 182 | assert!(output.status.success(), "{stdout}"); |
| 183 | assert!(stdout.contains("Built as a plain HTML site; Dockerfile is not used (a container is only the fallback"), "{stdout}"); |
| 184 | let seen = seen.lock().unwrap(); |
| 185 | assert!(!seen.iter().any(|(path, _)| path.ends_with("/registry"))); |
| 186 | assert_eq!(seen.last().unwrap().1["detected"], "html"); |
| 187 | drop(seen); |
| 188 | // A server with nothing else to serve fails, saying so. |
| 189 | let server = checkout("container-only", &[("go.mod", "module x\n"), ("main.go", "package main\n")]); |
| 190 | let output = deploy(&server, &api, &[]); |
| 191 | assert_eq!(output.status.code(), Some(1)); |
| 192 | assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are switched off")); |
| 193 | // Build as Static site skips the server altogether. |
| 194 | let output = deploy(&dir, &api, &[("BUILD_AS", "static")]); |
| 195 | assert!(output.status.success()); |
| 196 | let detect = runner().arg("detect").current_dir(&server).env_remove("GIT_REMOTE").output().unwrap(); |
| 197 | assert_eq!( |
| 198 | String::from_utf8_lossy(&detect.stdout), |
| 199 | "kind=container |
| 200 | install= |
| 201 | build=docker build -f Dockerfile (written by g1t) . |
| 202 | output-dir= |
| 203 | framework=go |
| 204 | dockerfile= |
| 205 | port=8080 |
| 206 | " |
| 207 | ); |
| 208 | } |
| 209 | |
| 210 | /// Whether `npx` can run here: the Node.js tests below need it, and the |
| 211 | /// network to install their packages. |
| 212 | fn has_npx() -> bool { |
| 213 | Command::new(if cfg!(windows) { "npx.cmd" } else { "npx" }).arg("--version").output().is_ok_and(|output| output.status.success()) |
| 214 | } |
| 215 | |
| 216 | /// One of the Node.js tests at a time: two `npx --yes wrangler@4` installing |
| 217 | /// into an empty npx cache at once leave it broken for both. |
| 218 | static NPX: Mutex<()> = Mutex::new(()); |
| 219 | |
| 220 | #[test] |
| 221 | #[ignore = "installs Express and wrangler from npm, and runs the Worker with wrangler dev"] |
| 222 | fn an_express_server_deploys_as_a_worker() { |
| 223 | let _one = NPX.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 224 | if !has_npx() { |
| 225 | return; |
| 226 | } |
| 227 | let (api, seen) = fake_api(); |
| 228 | let dir = checkout( |
| 229 | "express-worker", |
| 230 | &[ |
| 231 | ("package.json", r#"{ "private": true, "scripts": { "start": "node server.js" }, "dependencies": { "express": "5.1.0" } }"#), |
| 232 | ( |
| 233 | "server.js", |
| 234 | "const express = require('express');\nconst app = express();\napp.use(express.static('public'));\napp.get('/api', (req, res) => res.json({ ok: true, url: process.env.PUBLIC_URL }));\napp.listen(process.env.PORT || 3000, '0.0.0.0');\n", |
| 235 | ), |
| 236 | ("public/index.html", "<h1>hello</h1>"), |
| 237 | ], |
| 238 | ); |
| 239 | let output = deploy(&dir, &api, &[]); |
| 240 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 241 | assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); |
| 242 | // The build's log, for `--nocapture`. |
| 243 | eprintln!("{stdout}"); |
| 244 | assert!(stdout.contains("Running this Express server as a Worker, from `server.js`"), "{stdout}"); |
| 245 | assert!(stdout.contains("It started and answered a request, as a Worker."), "{stdout}"); |
| 246 | assert!(stdout.contains("Serving `public` as the Worker's static files"), "{stdout}"); |
| 247 | let seen = seen.lock().unwrap(); |
| 248 | assert!(!seen.iter().any(|(path, _)| path.ends_with("/registry"))); |
| 249 | let finish = &seen.last().unwrap().1; |
| 250 | assert_eq!((finish["detected"].as_str(), finish["framework"].as_str()), (Some("workers"), Some("node"))); |
| 251 | assert_eq!(finish["worker"]["mainModule"], "g1t-entry.js"); |
| 252 | assert_eq!(finish["worker"]["compatibilityFlags"], json!(["nodejs_compat"])); |
| 253 | let manifest = seen.iter().find(|(path, _)| path.ends_with("/session")).unwrap().1["manifest"].as_object().unwrap().clone(); |
| 254 | assert_eq!(manifest.keys().collect::<Vec<_>>(), ["/index.html"]); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | #[ignore = "installs packages from npm and bundles them with wrangler"] |
| 259 | fn a_server_that_cannot_run_as_a_worker_falls_back_to_a_container() { |
| 260 | let _one = NPX.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 261 | if !has_npx() { |
| 262 | return; |
| 263 | } |
| 264 | let (api, seen) = fake_api(); |
| 265 | // A module the bundle cannot find: wrangler fails, and the container is |
| 266 | // next (switched off here, so the build fails saying both). |
| 267 | let dir = checkout( |
| 268 | "worker-fallback", |
| 269 | &[ |
| 270 | ("package.json", r#"{ "private": true, "scripts": { "start": "node server.js" }, "dependencies": { "express": "5.1.0" } }"#), |
| 271 | ("server.js", "require('./missing-module');\nrequire('express')().listen(3000);\n"), |
| 272 | ], |
| 273 | ); |
| 274 | let output = deploy(&dir, &api, &[]); |
| 275 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 276 | assert_eq!(output.status.code(), Some(1), "{stdout}"); |
| 277 | assert!(stdout.contains("Could not run as a Worker: `npx --yes wrangler@4 deploy --dry-run"), "{stdout}"); |
| 278 | assert!(stdout.contains("; deploying as a container."), "{stdout}"); |
| 279 | assert!(stdout.contains("Docker deployments are switched off"), "{stdout}"); |
| 280 | assert!(seen.lock().unwrap().iter().any(|(path, _)| path.ends_with("/registry"))); |
| 281 | // A top-level await that never settles bundles, and fails as it starts. |
| 282 | let (api, _) = fake_api(); |
| 283 | let unsettled = checkout( |
| 284 | "worker-unsettled", |
| 285 | &[ |
| 286 | ("package.json", r#"{ "private": true, "type": "module", "scripts": { "start": "node server.js" }, "dependencies": { "express": "5.1.0" } }"#), |
| 287 | ("server.js", "import express from 'express';\nawait new Promise((resolve) => setTimeout(resolve, 10));\nexpress().listen(3000);\n"), |
| 288 | ], |
| 289 | ); |
| 290 | let output = deploy(&unsettled, &api, &[]); |
| 291 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 292 | assert_eq!(output.status.code(), Some(1), "{stdout}"); |
| 293 | assert!(stdout.contains("Could not run as a Worker: it failed as it started: Uncaught"), "{stdout}"); |
| 294 | } |