1pub mod player;
7pub mod python_bundle;
8
9pub use player::{candidate_player_names, player_bin_name};
10
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result};
15use pite_render::atlas::{sheet_for_sidecar, SIDECAR_EXT};
16
17pub const SUPPORTED_PLATFORMS: &[&str] = &["linux", "windows", "macos"];
18
19#[derive(Debug, Clone)]
20pub struct ExportOptions {
21 pub platform: String,
22 pub out_dir: Option<PathBuf>,
23 pub binary: Option<PathBuf>,
24 pub skip_python_check: bool,
25 pub bundle_python: bool,
27}
28
29impl Default for ExportOptions {
30 fn default() -> Self {
31 Self {
32 platform: String::new(),
33 out_dir: None,
34 binary: None,
35 skip_python_check: false,
36 bundle_python: true,
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum PythonChoice {
44 Bundled { zip: PathBuf },
46 System,
48}
49
50#[derive(Debug)]
51pub struct ExportReport {
52 pub out_dir: PathBuf,
53 pub binary: PathBuf,
54 pub files: Vec<PathBuf>,
55 pub main_scene: String,
56 pub python: PythonChoice,
57}
58
59pub fn find_python() -> Option<PathBuf> {
60 if let Some(p) = std::env::var_os("PITE_PYTHON") {
61 let p = PathBuf::from(p);
62 return is_python312(&p).then_some(p);
63 }
64 let path = std::env::var_os("PATH")?;
65 for dir in std::env::split_paths(&path) {
66 for name in ["python3.12", "python3.12.exe"] {
67 let p = dir.join(name);
68 if is_python312(&p) {
69 return Some(p);
70 }
71 }
72 }
73 None
74}
75
76fn is_python312(p: &Path) -> bool {
77 if !p.is_file() {
78 return false;
79 }
80 let Ok(out) = std::process::Command::new(p).arg("--version").output() else {
81 return false;
82 };
83 let text = format!(
84 "{}{}",
85 String::from_utf8_lossy(&out.stdout),
86 String::from_utf8_lossy(&out.stderr)
87 );
88 text.contains("Python 3.12")
89}
90
91struct ExportDir {
96 path: PathBuf,
97 complete: bool,
98}
99
100impl ExportDir {
101 fn commit(&mut self) {
102 self.complete = true;
103 }
104}
105
106impl Drop for ExportDir {
107 fn drop(&mut self) {
108 if !self.complete {
109 let _ = std::fs::remove_dir_all(&self.path);
110 }
111 }
112}
113
114pub fn export_project(root: &Path, opts: &ExportOptions) -> Result<ExportReport> {
115 if !SUPPORTED_PLATFORMS.contains(&opts.platform.as_str()) {
116 anyhow::bail!(
117 "unsupported export platform {:?} (supported: linux, windows, macos)",
118 opts.platform
119 );
120 }
121 let manifest = pite_project::load_manifest(root)?;
122 let game_name = manifest
123 .export
124 .binary_name
125 .clone()
126 .unwrap_or_else(|| manifest.project.name.clone());
127 let python = if opts.bundle_python {
128 let spec = python_bundle::pinned(&opts.platform).with_context(|| {
129 format!(
130 "no pinned Python build for {}; pass --no-bundle-python to require \
131 system Python 3.12 instead",
132 opts.platform
133 )
134 })?;
135 let archive = python_bundle::ensure_archive(&spec)?;
136 PythonChoice::Bundled {
137 zip: archive.clone(),
138 }
139 } else {
140 if !opts.skip_python_check && find_python().is_none() {
141 anyhow::bail!(
142 "export needs system Python 3.12 on PATH (or PITE_PYTHON); \
143 the target machine must have it too. Pass --skip-python-check \
144 for cross-machine builds, or drop --no-bundle-python to ship a \
145 pinned interpreter."
146 );
147 }
148 PythonChoice::System
149 };
150 let binary_src = select_binary(opts)?;
151
152 let out = opts.out_dir.clone().unwrap_or_else(|| {
153 root.join("dist")
154 .join(format!("{game_name}-{}", opts.platform))
155 });
156 if out.exists() {
157 anyhow::bail!("{} exists; remove it or pass --out <dir>", out.display());
158 }
159 let mut owned = ExportDir {
160 path: out.clone(),
161 complete: false,
162 };
163 let bin_name = if opts.platform == "windows" {
164 format!("{game_name}.exe")
165 } else {
166 game_name.clone()
167 };
168 let game_dir = out.join("game");
169 let bin_dir = out.join("bin");
170 std::fs::create_dir_all(&game_dir)?;
171 std::fs::create_dir_all(&bin_dir)?;
172
173 let mut files = collect_referenced(root, &manifest)?;
174 files.sort();
175 for abs in &files {
176 let rel = abs.strip_prefix(root).with_context(|| {
177 format!("export cannot reach outside the project: {}", abs.display())
178 })?;
179 let dest = game_dir.join(rel);
180 if let Some(parent) = dest.parent() {
181 std::fs::create_dir_all(parent)?;
182 }
183 std::fs::copy(abs, &dest)?;
184 }
185 std::fs::copy(
186 root.join(pite_project::MANIFEST_FILE),
187 game_dir.join(pite_project::MANIFEST_FILE),
188 )?;
189
190 let bin_dest = bin_dir.join(&bin_name);
191 std::fs::copy(&binary_src, &bin_dest)?;
192 python_bundle::strip_debug(&bin_dest);
193 #[cfg(unix)]
194 {
195 use std::os::unix::fs::PermissionsExt;
196 std::fs::set_permissions(&bin_dest, std::fs::Permissions::from_mode(0o755))?;
197 }
198
199 let staged = match &python {
200 PythonChoice::Bundled { zip } => {
201 let spec = python_bundle::pinned(&opts.platform).expect("checked above");
202 let mut staged = python_bundle::stage(&spec, zip, &out.join("python"))
203 .context("cannot stage the bundled Python")?;
204 if opts.platform == "windows" {
205 let dll = staged
209 .lib
210 .file_name()
211 .expect("lib file has a name")
212 .to_string_lossy()
213 .into_owned();
214 let dest = bin_dir.join(&dll);
215 std::fs::copy(&staged.lib, &dest)
216 .context("cannot place the interpreter DLL next to the binary")?;
217 std::fs::remove_file(&staged.lib)?;
218 staged.lib = dest;
219 }
220 if let Some(pth) = spec.pth_name {
221 let pth_body = windows_pth(&bin_dir, &staged)?;
222 std::fs::write(bin_dir.join(pth), pth_body)
223 .context("cannot write the interpreter loader config")?;
224 }
225 Some(staged)
226 }
227 PythonChoice::System => None,
228 };
229
230 let main_rel = manifest
231 .project
232 .main_scene
233 .strip_prefix("res://")
234 .map(str::to_string)
235 .unwrap_or_else(|| manifest.project.main_scene.clone());
236 if opts.platform == "windows" {
237 std::fs::write(
238 out.join("run.bat"),
239 windows_launcher(&game_name, &bin_name, &main_rel, staged.as_ref()),
240 )?;
241 } else {
242 let sh = out.join("run.sh");
243 std::fs::write(
244 &sh,
245 unix_launcher(&game_name, &bin_name, &main_rel, &out, staged.as_ref()),
246 )?;
247 #[cfg(unix)]
248 {
249 use std::os::unix::fs::PermissionsExt;
250 std::fs::set_permissions(&sh, std::fs::Permissions::from_mode(0o755))?;
251 }
252 }
253 std::fs::write(
254 out.join("README.txt"),
255 readme(&game_name, &opts.platform, &python),
256 )?;
257
258 let python = match staged {
259 Some(staged) => PythonChoice::Bundled {
260 zip: staged.stdlib_zip,
261 },
262 None => PythonChoice::System,
263 };
264 owned.commit();
265 Ok(ExportReport {
266 out_dir: out,
267 binary: bin_dest,
268 files,
269 main_scene: manifest.project.main_scene.clone(),
270 python,
271 })
272}
273
274fn unix_launcher(
278 game: &str,
279 bin: &str,
280 main_rel: &str,
281 out: &Path,
282 bundle: Option<&python_bundle::StagedBundle>,
283) -> String {
284 match bundle {
285 Some(bundle) => {
286 let lib_dir = bundle.lib.parent().unwrap_or(&bundle.root);
287 format!(
288 "#!/bin/sh\n\
289 set -e\n\
290 HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
291 LD_LIBRARY_PATH=\"$HERE/{lib_rel}\"${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\n\
292 export LD_LIBRARY_PATH\n\
293 DYLD_LIBRARY_PATH=\"$HERE/{lib_rel}\"${{DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}}\n\
294 export DYLD_LIBRARY_PATH\n\
295 PYTHONPATH=\"$HERE/{zip_rel}\"\n\
296 export PYTHONPATH\n\
297 exec \"$HERE/bin/{bin}\" \"$HERE/game/{main_rel}\" \"$@\"\n",
298 lib_rel = rel_to(out, lib_dir),
299 zip_rel = rel_to(out, &bundle.stdlib_zip),
300 )
301 }
302 None => format!(
303 "#!/bin/sh\n\
304 set -e\n\
305 HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
306 if [ -n \"$PITE_PYTHON\" ]; then PY=\"$PITE_PYTHON\"; else PY=\"python3.12\"; fi\n\
307 if ! command -v \"$PY\" >/dev/null 2>&1; then\n\
308 echo \"error: {game} needs system Python 3.12 (tried $PY).\" >&2\n\
309 echo \"This export has no bundled interpreter; install CPython 3.12 or set PITE_PYTHON.\" >&2\n\
310 exit 1\n\
311 fi\n\
312 exec \"$HERE/bin/{bin}\" \"$HERE/game/{main_rel}\" \"$@\"\n"
313 ),
314 }
315}
316
317fn windows_pth(bin_dir: &Path, bundle: &python_bundle::StagedBundle) -> Result<String> {
323 let python_dir = bundle
324 .root
325 .file_name()
326 .context("staged python directory has a name")?
327 .to_string_lossy();
328 let zip = bundle
329 .stdlib_zip
330 .file_name()
331 .context("staged stdlib zip has a name")?
332 .to_string_lossy();
333 let parent = bin_dir.parent().context("bin directory has a parent")?;
334 if bundle.root.parent() != Some(parent) {
335 anyhow::bail!(
336 "staged python directory {} is not a sibling of {}; the loader config \
337 cannot name a relative path to the stdlib",
338 bundle.root.display(),
339 bin_dir.display()
340 );
341 }
342 Ok(format!("..\\{python_dir}\\{zip}\r\n.\r\n"))
343}
344
345fn windows_launcher(
349 game: &str,
350 bin: &str,
351 main_rel: &str,
352 bundle: Option<&python_bundle::StagedBundle>,
353) -> String {
354 match bundle {
355 Some(_) => format!(
356 "@echo off\r\n\
357 set HERE=%~dp0\r\n\
358 \"%HERE%bin\\{bin}\" \"%HERE%game/{main_rel}\" %*\r\n"
359 ),
360 None => format!(
361 "@echo off\r\n\
362 set HERE=%~dp0\r\n\
363 if defined PITE_PYTHON (set PY=%PITE_PYTHON%) else (set PY=python3.12)\r\n\
364 where %PY% >nul 2>nul\r\n\
365 if errorlevel 1 (\r\n\
366 echo error: {game} needs system Python 3.12 on PATH. 1>&2\r\n\
367 echo This export has no bundled interpreter; install CPython 3.12 or set PITE_PYTHON. 1>&2\r\n\
368 exit /b 1\r\n\
369 )\r\n\
370 \"%HERE%bin\\{bin}\" \"%HERE%game/{main_rel}\" %*\r\n"
371 ),
372 }
373}
374
375fn select_binary(opts: &ExportOptions) -> Result<PathBuf> {
381 select_binary_from(opts, std::env::current_exe().ok())
382}
383
384fn select_binary_from(opts: &ExportOptions, current_exe: Option<PathBuf>) -> Result<PathBuf> {
385 if let Some(p) = &opts.binary {
386 if !p.is_file() {
387 anyhow::bail!("engine binary not found: {}", p.display());
388 }
389 return Ok(p.clone());
390 }
391 if let Some(dir) = current_exe.as_ref().and_then(|e| e.parent()) {
392 for name in candidate_player_names(&opts.platform, std::env::consts::OS) {
393 let sibling = dir.join(name);
394 if sibling.is_file() {
395 return Ok(sibling);
396 }
397 }
398 }
399 match player::ensure_player_binary(&opts.platform) {
400 Ok(path) => Ok(path),
401 Err(e) => {
402 anyhow::bail!(
403 "export needs a pite-player binary for {} next to {} (engine bundles ship player \
404 binaries for all platforms), or pass --binary with a player binary; \
405 download fallback failed: {e:#}",
406 opts.platform,
407 current_exe
408 .as_deref()
409 .unwrap_or(Path::new("pite"))
410 .display()
411 )
412 }
413 }
414}
415
416fn rel_to(root: &Path, target: &Path) -> String {
418 target
419 .strip_prefix(root)
420 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
421 .unwrap_or_else(|_| target.to_string_lossy().into_owned())
422}
423
424fn readme(game: &str, platform: &str, python: &PythonChoice) -> String {
425 let python_line = match python {
426 PythonChoice::Bundled { .. } => format!(
427 "Python: bundled CPython {} (pinned build {})
428",
429 python_bundle::PINNED_PYTHON,
430 python_bundle::PINNED_BUILD
431 ),
432 PythonChoice::System => {
433 "Requires system Python 3.12 on PATH (PITE_PYTHON overrides detection).\n".to_string()
434 }
435 };
436 format!(
437 "{game} ({platform} export)\n\
438 \n\
439 Run: ./run.sh (or run.bat on Windows)\n\
440 {python_line}\
441 Exported runs never watch files: hot reload is a dev-only feature.\n"
442 )
443}
444
445fn stage_tree(dir: &Path, out: &mut std::collections::HashSet<PathBuf>) -> Result<()> {
447 for entry in std::fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
448 let path = entry
449 .with_context(|| format!("cannot list entry in {}", dir.display()))?
450 .path();
451 if path.is_dir() {
452 stage_tree(&path, out)?;
453 } else if path.is_file() {
454 out.insert(path);
455 }
456 }
457 Ok(())
458}
459
460fn collect_referenced(root: &Path, manifest: &pite_project::PiteManifest) -> Result<Vec<PathBuf>> {
461 let mut files = HashSet::new();
462 let mut scenes = vec![manifest.project.main_scene.clone()];
463 let mut visited: HashSet<String> = HashSet::new();
464 while let Some(scene_ref) = scenes.pop() {
465 if !visited.insert(scene_ref.clone()) {
466 continue;
467 }
468 let scene_path = resolve_ref(root, None, &scene_ref)
469 .with_context(|| format!("export cannot resolve scene {scene_ref:?}"))?;
470 files.insert(scene_path.clone());
471 let scene_dir = scene_path
472 .parent()
473 .map(Path::to_path_buf)
474 .unwrap_or_else(|| root.to_path_buf());
475 let doc = pite_scene::load_scene(&scene_path)
476 .with_context(|| format!("export cannot parse {}", scene_path.display()))?;
477 for node in &doc.node {
478 if let Some(script) = &node.script {
479 let p = resolve_ref(root, Some(&scene_dir), &script.path)
480 .with_context(|| format!("export cannot resolve script {:?}", script.path))?;
481 files.insert(p.clone());
482 for audio in played_assets(
483 &std::fs::read_to_string(&p)
484 .with_context(|| format!("export cannot read {}", p.display()))?,
485 ) {
486 files.insert(
487 resolve_ref(root, Some(&scene_dir), audio)
488 .with_context(|| format!("export cannot resolve audio {audio:?}"))?,
489 );
490 }
491 }
492 for value in node.props.values() {
493 if let toml::Value::String(s) = value {
494 if s.starts_with("res://") {
495 let asset = resolve_ref(root, Some(&scene_dir), s)
496 .with_context(|| format!("export cannot resolve asset {s:?}"))?;
497 if let Some(sheet) = atlas_sheet(&asset) {
498 files.insert(sheet);
499 }
500 files.insert(asset);
501 }
502 }
503 }
504 }
505 for inst in &doc.instance {
506 scenes.push(inst.scene.clone());
507 }
508 }
509 for extra in &manifest.export.include {
510 files.insert(root.join(extra));
511 }
512 let vendor_dir = root.join("vendor");
515 if vendor_dir.is_dir() {
516 stage_tree(&vendor_dir, &mut files)?;
517 }
518 let mut files: Vec<PathBuf> = files.into_iter().collect();
519 for f in &files {
520 if !f.is_file() {
521 anyhow::bail!("export references missing file: {}", f.display());
522 }
523 }
524 files.sort();
525 Ok(files)
526}
527
528fn resolve_ref(root: &Path, scene_dir: Option<&Path>, r: &str) -> Option<PathBuf> {
529 if let Some(rel) = r.strip_prefix("res://") {
530 return Some(root.join(rel));
531 }
532 let p = PathBuf::from(r);
533 if p.is_absolute() {
534 return Some(p);
535 }
536 scene_dir.map(|d| d.join(p))
537}
538
539fn atlas_sheet(atlas: &Path) -> Option<PathBuf> {
543 atlas
544 .file_name()?
545 .to_str()?
546 .ends_with(SIDECAR_EXT)
547 .then(|| sheet_for_sidecar(atlas))
548}
549
550fn played_assets(text: &str) -> Vec<&str> {
551 let mut out = Vec::new();
552 for line in text.lines() {
553 let line = line.split('#').next().unwrap_or("");
554 for marker in ["play(\"", "play('"] {
555 let mut rest = line;
556 while let Some(pos) = rest.find(marker) {
557 let before = &rest[..pos];
558 if before.ends_with(|c: char| c.is_alphanumeric() || c == '_') {
559 rest = &rest[pos + marker.len()..];
560 continue;
561 }
562 let quote = marker.as_bytes()[marker.len() - 1] as char;
563 rest = &rest[pos + marker.len()..];
564 if let Some(end) = rest.find(quote) {
565 out.push(&rest[..end]);
566 rest = &rest[end + 1..];
567 } else {
568 break;
569 }
570 }
571 }
572 }
573 out
574}
575
576#[cfg(test)]
577pub(crate) static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use crate::SERIAL;
583
584 const MAIN: &str = r#"format_version = 1
585root = "root"
586
587[[node]]
588id = "root"
589type = "Node2D"
590name = "Main"
591
592[[node]]
593id = "hero"
594type = "Sprite2D"
595name = "Hero"
596parent = "root"
597
598[node.props]
599atlas = "res://assets/sheet.atlas.json"
600frame = "player"
601
602[[node]]
603id = "spr"
604type = "Sprite2D"
605name = "Spr"
606parent = "root"
607
608[node.props]
609texture = "res://assets/a.png"
610
611[node.script]
612path = "res://scripts/m.py"
613class = "M"
614
615[[instance]]
616scene = "res://scenes/sub.ptscn"
617parent = "root"
618prefix = "s_"
619"#;
620
621 const SUB: &str = r#"format_version = 1
622root = "subroot"
623
624[[node]]
625id = "subroot"
626type = "Node"
627name = "Sub"
628
629[[node]]
630id = "pic"
631type = "Sprite2D"
632name = "Pic"
633parent = "subroot"
634
635[node.props]
636texture = "res://assets/b.png"
637"#;
638
639 const SCRIPT: &str = r#"import pite
640
641class M(pite.Node):
642 pass
643
644def _once():
645 pite.play("res://sfx/hit.wav")
646 # play("res://sfx/comment.wav")
647 display("res://sfx/nope.wav")
648"#;
649
650 const ATLAS: &str = r#"{
651 "texture": "sheet.png",
652 "size": [64, 32],
653 "frames": { "player": { "x": 0, "y": 0, "w": 32, "h": 32 } }
654}
655"#;
656
657 fn fixture(tag: &str) -> (PathBuf, PathBuf) {
658 let dir = std::env::temp_dir().join(format!("pite-exp-{tag}-{}", std::process::id()));
659 std::fs::create_dir_all(dir.join("scenes")).unwrap();
660 std::fs::create_dir_all(dir.join("scripts")).unwrap();
661 std::fs::create_dir_all(dir.join("assets")).unwrap();
662 std::fs::create_dir_all(dir.join("sfx")).unwrap();
663 std::fs::write(dir.join("pite.toml"), "[project]\nname = \"fixt\"\n").unwrap();
664 std::fs::write(dir.join("scenes").join("main.ptscn"), MAIN).unwrap();
665 std::fs::write(dir.join("scenes").join("sub.ptscn"), SUB).unwrap();
666 std::fs::write(dir.join("scripts").join("m.py"), SCRIPT).unwrap();
667 std::fs::write(dir.join("assets").join("a.png"), "A").unwrap();
668 std::fs::write(dir.join("assets").join("b.png"), "B").unwrap();
669 std::fs::write(dir.join("assets").join("unused.png"), "U").unwrap();
670 std::fs::write(dir.join("assets").join("sheet.atlas.json"), ATLAS).unwrap();
671 std::fs::write(dir.join("assets").join("sheet.png"), "S").unwrap();
672 std::fs::write(dir.join("assets").join("orphan.png"), "O").unwrap();
673 std::fs::write(dir.join("sfx").join("hit.wav"), "W").unwrap();
674 let fake_bin = dir.join("fakebin");
675 std::fs::write(&fake_bin, "FAKEBIN").unwrap();
676 (dir, fake_bin)
677 }
678
679 fn opts(bin: &Path) -> ExportOptions {
680 ExportOptions {
681 platform: "linux".to_string(),
682 out_dir: None,
683 binary: Some(bin.to_path_buf()),
684 skip_python_check: true,
685 bundle_python: false,
686 }
687 }
688
689 fn game_has(out: &Path, rel: &str) -> bool {
690 out.join("game").join(rel).is_file()
691 }
692
693 #[test]
694 fn layout_copies_only_referenced_files() {
695 let (dir, bin) = fixture("layout");
696 let report = export_project(&dir, &opts(&bin)).unwrap();
697 let out = &report.out_dir;
698 for rel in [
699 "pite.toml",
700 "scenes/main.ptscn",
701 "scenes/sub.ptscn",
702 "scripts/m.py",
703 "assets/a.png",
704 "assets/b.png",
705 "assets/sheet.atlas.json",
706 "assets/sheet.png",
707 "sfx/hit.wav",
708 ] {
709 assert!(game_has(out, rel), "missing {rel}");
710 }
711 assert!(!game_has(out, "assets/unused.png"));
712 assert!(!game_has(out, "assets/orphan.png"));
713 assert!(out.join("bin").join("fixt").is_file());
714 let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
715 assert!(sh.contains("bin/fixt"));
716 assert!(!sh.contains(" run "));
717 assert!(sh.contains("game/scenes/main.ptscn"));
718 assert!(out.join("README.txt").is_file());
719 std::fs::remove_dir_all(&dir).ok();
720 std::fs::remove_dir_all(out).ok();
721 }
722
723 #[test]
724 fn layout_stages_vendor_whole() {
725 let (dir, bin) = fixture("vendor-stage");
726 std::fs::create_dir_all(dir.join("vendor").join("vpkg")).unwrap();
727 std::fs::write(dir.join("vendor").join("vpkg").join("__init__.py"), b"x=1").unwrap();
728 std::fs::write(
729 dir.join("vendor").join("pure-1.0-py3-none-any.whl"),
730 b"fake-wheel",
731 )
732 .unwrap();
733 let report = export_project(&dir, &opts(&bin)).unwrap();
734 let out = &report.out_dir;
735 assert!(game_has(out, "vendor/vpkg/__init__.py"), "missing tree");
736 assert!(
737 game_has(out, "vendor/pure-1.0-py3-none-any.whl"),
738 "missing wheel"
739 );
740 std::fs::remove_dir_all(&dir).ok();
741 std::fs::remove_dir_all(out).ok();
742 }
743
744 #[test]
745 fn missing_python_fails_loudly() {
746 let _guard = SERIAL.lock().unwrap();
747 let prev = std::env::var_os("PITE_PYTHON");
748 std::env::set_var("PITE_PYTHON", "/nonexistent/python3.12");
749 let (dir, bin) = fixture("nopy");
750 let err = export_project(
751 &dir,
752 &ExportOptions {
753 skip_python_check: false,
754 ..opts(&bin)
755 },
756 )
757 .unwrap_err();
758 assert!(err.to_string().contains("Python 3.12"), "{err:#}");
759 match prev {
760 Some(v) => std::env::set_var("PITE_PYTHON", v),
761 None => std::env::remove_var("PITE_PYTHON"),
762 }
763 std::fs::remove_dir_all(&dir).ok();
764 }
765
766 #[test]
767 fn python_override_passes_gate() {
768 let _guard = SERIAL.lock().unwrap();
769 let prev = std::env::var_os("PITE_PYTHON");
770 let (dir, bin) = fixture("pyok");
771 let fake = dir.join("fake312");
772 std::fs::write(&fake, "#!/bin/sh\necho \"Python 3.12.99\"\n").unwrap();
773 #[cfg(unix)]
774 {
775 use std::os::unix::fs::PermissionsExt;
776 std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
777 }
778 std::env::set_var("PITE_PYTHON", &fake);
779 let report = export_project(
780 &dir,
781 &ExportOptions {
782 skip_python_check: false,
783 ..opts(&bin)
784 },
785 )
786 .unwrap();
787 match prev {
788 Some(v) => std::env::set_var("PITE_PYTHON", v),
789 None => std::env::remove_var("PITE_PYTHON"),
790 }
791 std::fs::remove_dir_all(&report.out_dir).ok();
792 std::fs::remove_dir_all(&dir).ok();
793 }
794
795 #[test]
796 fn bundling_stages_runtime_and_stdlib_next_to_the_game() {
797 let Some(spec) = python_bundle::pinned("linux") else {
798 panic!("linux must have a pinned bundle");
799 };
800 let dir = std::env::temp_dir().join(format!("pite-stage-{}", std::process::id()));
801 let _ = std::fs::remove_dir_all(&dir);
802 std::fs::create_dir_all(dir.join("python/lib/python3.12/site-packages")).unwrap();
803 std::fs::write(
804 dir.join("python/lib").join("libpython3.12.so.1.0"),
805 b"fake-so",
806 )
807 .unwrap();
808 std::fs::write(
809 dir.join("python/lib/python3.12").join("os.py"),
810 b"import sys\n",
811 )
812 .unwrap();
813 std::fs::write(
814 dir.join("python/lib/python3.12/site-packages")
815 .join("pip.py"),
816 b"raise SystemExit\n",
817 )
818 .unwrap();
819 let archive = dir.join("archive.tar.gz");
820 let tar = std::process::Command::new("tar")
821 .arg("-czf")
822 .arg(&archive)
823 .arg("-C")
824 .arg(&dir)
825 .arg("python")
826 .output();
827 if !tar.as_ref().is_ok_and(|o| o.status.success()) {
828 eprintln!("skipped: tar unavailable");
829 return;
830 }
831 let out = dir.join("out");
832 let staged = python_bundle::stage(&spec, &archive, &out).unwrap();
833 assert!(staged.lib.ends_with("libpython3.12.so.1.0"), "{staged:?}");
834 assert!(staged.lib.is_file());
835 assert!(staged.stdlib_zip.is_file());
836 let listing = std::process::Command::new("python3")
837 .arg("-c")
838 .arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
839 .arg(&staged.stdlib_zip)
840 .output()
841 .expect("python3 lists the zip");
842 let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
843 assert!(listing.contains("os.py"), "{listing}");
844 assert!(!listing.contains("site-packages"), "{listing}");
845 assert!(
846 !out.join(".staging").exists(),
847 "staging dir must be cleaned up"
848 );
849 std::fs::remove_dir_all(&dir).ok();
850 }
851
852 #[test]
853 fn macos_bundling_stages_runtime_and_stdlib_next_to_the_game() {
854 let Some(spec) = python_bundle::pinned("macos") else {
855 panic!("macos must have a pinned bundle");
856 };
857 let dir = std::env::temp_dir().join(format!("pite-stage-mac-{}", std::process::id()));
858 let _ = std::fs::remove_dir_all(&dir);
859 std::fs::create_dir_all(dir.join("python/lib/python3.12/site-packages")).unwrap();
860 std::fs::write(
861 dir.join("python/lib").join("libpython3.12.dylib"),
862 b"fake-dylib",
863 )
864 .unwrap();
865 std::fs::write(
866 dir.join("python/lib/python3.12").join("os.py"),
867 b"import sys\n",
868 )
869 .unwrap();
870 std::fs::write(
871 dir.join("python/lib/python3.12/site-packages")
872 .join("pip.py"),
873 b"raise SystemExit\n",
874 )
875 .unwrap();
876 let archive = dir.join("archive.tar.gz");
877 let tar = std::process::Command::new("tar")
878 .arg("-czf")
879 .arg(&archive)
880 .arg("-C")
881 .arg(&dir)
882 .arg("python")
883 .output();
884 if !tar.as_ref().is_ok_and(|o| o.status.success()) {
885 eprintln!("skipped: tar unavailable");
886 return;
887 }
888 let out = dir.join("out");
889 let staged = python_bundle::stage(&spec, &archive, &out).unwrap();
890 assert!(staged.lib.ends_with("libpython3.12.dylib"), "{staged:?}");
891 assert!(staged.lib.is_file());
892 assert!(staged.stdlib_zip.is_file());
893 let listing = std::process::Command::new("python3")
894 .arg("-c")
895 .arg("import zipfile,sys; print(sorted(zipfile.ZipFile(sys.argv[1]).namelist()))")
896 .arg(&staged.stdlib_zip)
897 .output()
898 .expect("python3 lists the zip");
899 let listing = String::from_utf8_lossy(&listing.stdout).into_owned();
900 assert!(listing.contains("os.py"), "{listing}");
901 assert!(!listing.contains("site-packages"), "{listing}");
902 assert!(
903 !out.join(".staging").exists(),
904 "staging dir must be cleaned up"
905 );
906 std::fs::remove_dir_all(&dir).ok();
907 }
908
909 #[test]
912 fn bundled_export_layout_has_runtime_and_stdlib() {
913 if std::env::var("PITE_BUNDLE_SMOKE").is_err() {
914 eprintln!("skipped: set PITE_BUNDLE_SMOKE=1 (downloads the pinned Python once)");
915 return;
916 }
917 let _guard = SERIAL.lock().unwrap();
918 let (dir, bin) = fixture("bundle");
919 let report = export_project(
920 &dir,
921 &ExportOptions {
922 platform: "linux".to_string(),
923 out_dir: None,
924 binary: Some(bin.clone()),
925 skip_python_check: false,
926 bundle_python: true,
927 },
928 )
929 .expect("bundled export must succeed");
930 match &report.python {
931 PythonChoice::Bundled { zip } => assert!(zip.is_file(), "{zip:?}"),
932 other => panic!("expected a bundled interpreter, got {other:?}"),
933 }
934 let out = &report.out_dir;
935 assert!(
936 out.join("bin").join("fixt").is_file(),
937 "engine binary missing"
938 );
939 assert!(
940 python_bundle::pinned("linux").is_some_and(|spec| {
941 let staged = spec.lib.rsplit('/').next().unwrap_or(spec.lib);
942 out.join("python/lib").join(staged).is_file()
943 }),
944 "bundled libpython missing under python/lib"
945 );
946 assert!(
947 out.join("python").join("python312.zip").is_file(),
948 "stdlib zip missing"
949 );
950 let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
951 assert!(sh.contains("LD_LIBRARY_PATH"), "{sh}");
952 assert!(sh.contains("PYTHONPATH"), "{sh}");
953 assert!(
954 !sh.contains("needs system Python"),
955 "bundled launcher must not gate"
956 );
957 let readme = std::fs::read_to_string(out.join("README.txt")).unwrap();
958 assert!(readme.contains("bundled CPython"), "{readme}");
959 std::fs::remove_dir_all(&report.out_dir).ok();
960 std::fs::remove_dir_all(&dir).ok();
961 }
962
963 #[test]
964 fn bundling_without_network_support_points_at_the_opt_out() {
965 let _guard = SERIAL.lock().unwrap();
966 let prev = std::env::var_os("PITE_TOOLCHAIN_DIR");
967 let dir = std::env::temp_dir().join(format!("pite-offline-{}", std::process::id()));
968 std::env::set_var("PITE_TOOLCHAIN_DIR", &dir);
969 let err = export_project(
970 &dir,
971 &ExportOptions {
972 platform: "ps5".to_string(),
973 ..opts(Path::new("unused"))
974 },
975 )
976 .unwrap_err();
977 assert!(
978 err.to_string().contains("unsupported export platform"),
979 "{err:#}"
980 );
981 match prev {
982 Some(v) => std::env::set_var("PITE_TOOLCHAIN_DIR", v),
983 None => std::env::remove_var("PITE_TOOLCHAIN_DIR"),
984 }
985 std::fs::remove_dir_all(&dir).ok();
986 }
987
988 #[test]
989 fn exported_manifest_runs_round_trip() {
990 let (dir, bin) = fixture("roundtrip");
991 let report = export_project(&dir, &opts(&bin)).unwrap();
992 let scene = report
993 .out_dir
994 .join("game")
995 .join("scenes")
996 .join("main.ptscn");
997 let manifest = pite_project::load_manifest(&report.out_dir.join("game")).unwrap();
998 assert_eq!(manifest.project.name, "fixt");
999 let session =
1000 pite_runtime::GameSession::open(&scene, true).expect("exported scene must open");
1001 assert_eq!(session.tree_len(), 5);
1002 assert_eq!(session.script_count(), 1);
1003 std::fs::remove_dir_all(&report.out_dir).ok();
1004 std::fs::remove_dir_all(&dir).ok();
1005 }
1006
1007 #[cfg(unix)]
1012 #[test]
1013 fn failed_export_leaves_no_directory_behind() {
1014 use std::os::unix::fs::PermissionsExt;
1015 let _guard = SERIAL.lock().unwrap();
1016 let (dir, bin) = fixture("rollback");
1017 let out = dir.join("rollback-out");
1018 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
1019 if std::fs::read(&bin).is_ok() {
1020 eprintln!("skipped: cannot make the binary unreadable as this user");
1021 std::fs::remove_dir_all(&dir).ok();
1022 return;
1023 }
1024 let err = export_project(
1025 &dir,
1026 &ExportOptions {
1027 out_dir: Some(out.clone()),
1028 ..opts(&bin)
1029 },
1030 )
1031 .unwrap_err();
1032 assert!(
1033 err.to_string().to_lowercase().contains("permission")
1034 || err.to_string().to_lowercase().contains("denied"),
1035 "expected the copy to fail on permissions, got: {err:#}"
1036 );
1037 assert!(!out.exists(), "failed export left {} behind", out.display());
1038 std::fs::remove_dir_all(&dir).ok();
1039 }
1040
1041 #[test]
1046 fn windows_loader_config_names_the_stdlib_relatively() {
1047 let bundle = python_bundle::StagedBundle {
1048 root: PathBuf::from("/game/python"),
1049 lib: PathBuf::from("/game/python/lib/python312.dll"),
1050 stdlib_zip: PathBuf::from("/game/python/python312.zip"),
1051 };
1052 let pth = windows_pth(Path::new("/game/bin"), &bundle).unwrap();
1053 assert_eq!(pth, "..\\python\\python312.zip\r\n.\r\n");
1054 assert!(
1055 !pth.contains("/game"),
1056 "loader config must stay relative: {pth:?}"
1057 );
1058 }
1059
1060 #[test]
1063 fn windows_loader_config_rejects_an_unreachable_stdlib() {
1064 let bundle = python_bundle::StagedBundle {
1065 root: PathBuf::from("/elsewhere/python"),
1066 lib: PathBuf::from("/elsewhere/python/lib/python312.dll"),
1067 stdlib_zip: PathBuf::from("/elsewhere/python/python312.zip"),
1068 };
1069 let err = windows_pth(Path::new("/game/bin"), &bundle)
1070 .unwrap_err()
1071 .to_string();
1072 assert!(err.contains("not a sibling"), "{err}");
1073 }
1074
1075 #[test]
1078 fn windows_export_ships_the_interpreter_beside_the_binary() {
1079 let Some(spec) = python_bundle::pinned("windows") else {
1080 panic!("windows must have a pinned bundle");
1081 };
1082 if !python_bundle::archive_path(&spec).is_file() {
1083 eprintln!("skipped: windows archive not cached (export once to populate it)");
1084 return;
1085 }
1086 let _guard = SERIAL.lock().unwrap();
1087 let (dir, bin) = fixture("winbundle");
1088 let report = export_project(
1089 &dir,
1090 &ExportOptions {
1091 platform: "windows".to_string(),
1092 out_dir: None,
1093 binary: Some(bin),
1094 skip_python_check: false,
1095 bundle_python: true,
1096 },
1097 )
1098 .expect("windows bundled export must succeed");
1099 let out = &report.out_dir;
1100 let dll = spec.lib.rsplit('/').next().unwrap_or(spec.lib);
1101 assert!(
1102 out.join("bin").join(dll).is_file(),
1103 "interpreter must sit next to the exe, not in python/lib"
1104 );
1105 assert!(
1106 !out.join("python/lib").join(dll).exists(),
1107 "interpreter must not ship twice"
1108 );
1109 let pth = out
1110 .join("bin")
1111 .join(spec.pth_name.expect("windows needs a loader config"));
1112 let body = std::fs::read_to_string(&pth).unwrap();
1113 assert!(
1114 body.contains("python312.zip") && !body.contains(&out.display().to_string()),
1115 "loader config must name the zip relatively: {body:?}"
1116 );
1117 let bat = std::fs::read_to_string(out.join("run.bat")).unwrap();
1118 assert!(!bat.contains("PYTHONPATH"), "{bat}");
1119 assert!(
1120 !bat.contains("needs system Python"),
1121 "bundled launcher must not gate"
1122 );
1123 assert!(
1124 !out.join("python/.staging").exists(),
1125 "staging dir must be cleaned up"
1126 );
1127 std::fs::remove_dir_all(&report.out_dir).ok();
1128 std::fs::remove_dir_all(&dir).ok();
1129 }
1130
1131 fn bin_opts(platform: &str, binary: Option<PathBuf>) -> ExportOptions {
1132 ExportOptions {
1133 platform: platform.to_string(),
1134 out_dir: None,
1135 binary,
1136 skip_python_check: true,
1137 bundle_python: false,
1138 }
1139 }
1140
1141 fn sel_dir(tag: &str) -> PathBuf {
1142 let dir = std::env::temp_dir().join(format!(
1143 "pite-sel-{tag}-{}-{}",
1144 std::process::id(),
1145 std::time::SystemTime::now()
1146 .duration_since(std::time::UNIX_EPOCH)
1147 .map(|d| d.subsec_nanos())
1148 .unwrap_or(0)
1149 ));
1150 std::fs::create_dir_all(&dir).unwrap();
1151 dir
1152 }
1153
1154 #[test]
1155 fn explicit_binary_wins_over_sibling() {
1156 let dir = sel_dir("explicit");
1157 let fake_exe = dir.join("pite");
1158 let explicit = dir.join("custom-player");
1159 let sibling = dir.join(player_bin_name(std::env::consts::OS));
1160 for p in [&fake_exe, &explicit, &sibling] {
1161 std::fs::write(p, "x").unwrap();
1162 }
1163 let got = select_binary_from(
1164 &bin_opts(std::env::consts::OS, Some(explicit.clone())),
1165 Some(fake_exe),
1166 )
1167 .unwrap();
1168 assert_eq!(got, explicit);
1169 std::fs::remove_dir_all(&dir).ok();
1170 }
1171
1172 #[test]
1173 fn sibling_player_serves_native_export() {
1174 let dir = sel_dir("sibling");
1175 let fake_exe = dir.join("pite");
1176 let sibling = dir.join(player_bin_name(std::env::consts::OS));
1177 std::fs::write(&fake_exe, "x").unwrap();
1178 std::fs::write(&sibling, "x").unwrap();
1179 let got =
1180 select_binary_from(&bin_opts(std::env::consts::OS, None), Some(fake_exe)).unwrap();
1181 assert_eq!(got, sibling);
1182 std::fs::remove_dir_all(&dir).ok();
1183 }
1184
1185 #[test]
1186 fn missing_sibling_fails_with_build_hint() {
1187 let _guard = SERIAL.lock().unwrap();
1188 let dir = sel_dir("nosibling");
1189 let fake_exe = dir.join("pite");
1190 std::fs::write(&fake_exe, "x").unwrap();
1191 let empty_cache = dir.join("empty_cache");
1192 std::fs::create_dir_all(&empty_cache).unwrap();
1193 std::env::set_var("PITE_PLAYER_DIR", &empty_cache);
1194 std::env::set_var("PITE_PLAYER_URL", "http://127.0.0.1:9/nonexistent");
1195 let err = select_binary_from(&bin_opts(std::env::consts::OS, None), Some(fake_exe))
1196 .unwrap_err()
1197 .to_string();
1198 assert!(err.contains("pite-player"), "got: {err}");
1199 assert!(err.contains("--binary"), "got: {err}");
1200 std::env::remove_var("PITE_PLAYER_DIR");
1201 std::env::remove_var("PITE_PLAYER_URL");
1202 std::fs::remove_dir_all(&dir).ok();
1203 }
1204
1205 #[test]
1206 fn missing_sibling_uses_cached_player() {
1207 let _guard = SERIAL.lock().unwrap();
1208 let dir = sel_dir("cached_player");
1209 let fake_exe = dir.join("pite");
1210 std::fs::write(&fake_exe, "x").unwrap();
1211 let cache = dir.join("cache");
1212 let platform = std::env::consts::OS;
1213 let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1214 let cached_bin = cache
1215 .join(&version)
1216 .join(platform)
1217 .join(player_bin_name(platform));
1218 std::fs::create_dir_all(cached_bin.parent().unwrap()).unwrap();
1219 std::fs::write(&cached_bin, "cached-binary-content").unwrap();
1220
1221 std::env::set_var("PITE_PLAYER_DIR", &cache);
1222 let got = select_binary_from(&bin_opts(platform, None), Some(fake_exe)).unwrap();
1223 assert_eq!(got, cached_bin);
1224 std::env::remove_var("PITE_PLAYER_DIR");
1225 std::fs::remove_dir_all(&dir).ok();
1226 }
1227
1228 #[test]
1229 fn missing_sibling_downloads_and_unpacks_player_tar_gz() {
1230 let _guard = SERIAL.lock().unwrap();
1231 let dir = sel_dir("dl_tar_gz");
1232 let fake_exe = dir.join("pite");
1233 std::fs::write(&fake_exe, "x").unwrap();
1234 let cache = dir.join("cache");
1235 let archive_src = dir.join("src");
1236 std::fs::create_dir_all(&archive_src).unwrap();
1237 let bin_src = archive_src.join("pite-player-linux");
1238 std::fs::write(&bin_src, "downloaded-player-content").unwrap();
1239 let archive_tar = dir.join("pite-player-v0.3.0-linux.tar.gz");
1240
1241 let status = std::process::Command::new("tar")
1242 .args([
1243 "-czf",
1244 archive_tar.to_str().unwrap(),
1245 "-C",
1246 archive_src.to_str().unwrap(),
1247 "pite-player-linux",
1248 ])
1249 .status()
1250 .unwrap();
1251 assert!(status.success());
1252
1253 std::env::set_var("PITE_PLAYER_DIR", &cache);
1254 std::env::set_var(
1255 "PITE_PLAYER_URL",
1256 format!("file://{}", archive_tar.display()),
1257 );
1258 let got = select_binary_from(&bin_opts("linux", None), Some(fake_exe)).unwrap();
1259 assert!(got.is_file());
1260 let content = std::fs::read_to_string(&got).unwrap();
1261 assert_eq!(content, "downloaded-player-content");
1262
1263 std::env::remove_var("PITE_PLAYER_DIR");
1264 std::env::remove_var("PITE_PLAYER_URL");
1265 std::fs::remove_dir_all(&dir).ok();
1266 }
1267
1268 #[test]
1269 fn missing_sibling_downloads_and_unpacks_player_zip() {
1270 let _guard = SERIAL.lock().unwrap();
1271 let dir = sel_dir("dl_zip");
1272 let fake_exe = dir.join("pite");
1273 std::fs::write(&fake_exe, "x").unwrap();
1274 let cache = dir.join("cache");
1275 let archive_zip = dir.join("pite-player-v0.3.0-windows.zip");
1276
1277 {
1278 let file = std::fs::File::create(&archive_zip).unwrap();
1279 let mut zip = zip::ZipWriter::new(file);
1280 let options = zip::write::SimpleFileOptions::default();
1281 zip.start_file("pite-player-windows.exe", options).unwrap();
1282 std::io::Write::write_all(&mut zip, b"windows-player-content").unwrap();
1283 zip.finish().unwrap();
1284 }
1285
1286 std::env::set_var("PITE_PLAYER_DIR", &cache);
1287 std::env::set_var(
1288 "PITE_PLAYER_URL",
1289 format!("file://{}", archive_zip.display()),
1290 );
1291 let got = select_binary_from(&bin_opts("windows", None), Some(fake_exe)).unwrap();
1292 assert!(got.is_file());
1293 let content = std::fs::read_to_string(&got).unwrap();
1294 assert_eq!(content, "windows-player-content");
1295
1296 std::env::remove_var("PITE_PLAYER_DIR");
1297 std::env::remove_var("PITE_PLAYER_URL");
1298 std::fs::remove_dir_all(&dir).ok();
1299 }
1300
1301 #[test]
1302 fn foreign_platform_uses_bundled_sibling() {
1303 let foreign = if std::env::consts::OS == "windows" {
1306 "linux"
1307 } else {
1308 "windows"
1309 };
1310 let dir = sel_dir("foreign");
1311 let fake_exe = dir.join("pite");
1312 let sibling = dir.join(player_bin_name(foreign));
1313 std::fs::write(&fake_exe, "x").unwrap();
1314 std::fs::write(&sibling, "x").unwrap();
1315 let got = select_binary_from(&bin_opts(foreign, None), Some(fake_exe)).unwrap();
1316 assert_eq!(got, sibling);
1317 std::fs::remove_dir_all(&dir).ok();
1318 }
1319
1320 #[test]
1321 fn foreign_macos_uses_bundled_sibling() {
1322 let dir = sel_dir("foreign-macos");
1323 let fake_exe = dir.join("pite");
1324 let sibling = dir.join("pite-player-macos");
1325 std::fs::write(&fake_exe, "x").unwrap();
1326 std::fs::write(&sibling, "x").unwrap();
1327 let got = select_binary_from(&bin_opts("macos", None), Some(fake_exe)).unwrap();
1328 assert_eq!(got, sibling);
1329 std::fs::remove_dir_all(&dir).ok();
1330 }
1331
1332 #[test]
1333 fn macos_export_layout_without_bundle() {
1334 let (dir, bin) = fixture("mac-layout");
1335 let report = export_project(
1336 &dir,
1337 &ExportOptions {
1338 platform: "macos".to_string(),
1339 out_dir: None,
1340 binary: Some(bin),
1341 skip_python_check: true,
1342 bundle_python: false,
1343 },
1344 )
1345 .unwrap();
1346 let out = &report.out_dir;
1347 assert!(out.join("bin").join("fixt").is_file());
1348 assert!(out.join("run.sh").is_file());
1349 let sh = std::fs::read_to_string(out.join("run.sh")).unwrap();
1350 assert!(sh.contains("needs system Python 3.12"));
1351 assert!(sh.contains("game/scenes/main.ptscn"));
1352 std::fs::remove_dir_all(&dir).ok();
1353 std::fs::remove_dir_all(out).ok();
1354 }
1355
1356 #[test]
1357 fn unix_launcher_sets_both_ld_and_dyld_library_paths() {
1358 let bundle = python_bundle::StagedBundle {
1359 root: PathBuf::from("/game/python"),
1360 lib: PathBuf::from("/game/python/lib/libpython3.12.dylib"),
1361 stdlib_zip: PathBuf::from("/game/python/python312.zip"),
1362 };
1363 let sh = unix_launcher(
1364 "game",
1365 "bin",
1366 "main.ptscn",
1367 Path::new("/game"),
1368 Some(&bundle),
1369 );
1370 assert!(sh.contains("LD_LIBRARY_PATH"));
1371 assert!(sh.contains("DYLD_LIBRARY_PATH"));
1372 assert!(sh.contains("PYTHONPATH"));
1373 }
1374
1375 #[test]
1376 fn unknown_platform_fails_loudly() {
1377 let (dir, bin) = fixture("plat");
1378 let err = export_project(
1379 &dir,
1380 &ExportOptions {
1381 platform: "ps5".to_string(),
1382 ..opts(&bin)
1383 },
1384 )
1385 .unwrap_err();
1386 assert!(err.to_string().contains("unsupported export platform"));
1387 std::fs::remove_dir_all(&dir).ok();
1388 }
1389}