craftkit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- craft_kit/SKILL.md +145 -0
- craft_kit/__init__.py +3 -0
- craft_kit/__main__.py +5 -0
- craft_kit/agent.py +165 -0
- craft_kit/banner.py +86 -0
- craft_kit/brief.py +101 -0
- craft_kit/browser.py +198 -0
- craft_kit/checks.py +219 -0
- craft_kit/cli.py +394 -0
- craft_kit/home.py +370 -0
- craft_kit/png.py +135 -0
- craft_kit/proc.py +161 -0
- craft_kit/study.py +170 -0
- craft_kit/tables.py +154 -0
- craft_kit/ui.py +284 -0
- craft_kit/workspace.py +320 -0
- craftkit-0.3.0.dist-info/METADATA +174 -0
- craftkit-0.3.0.dist-info/RECORD +23 -0
- craftkit-0.3.0.dist-info/WHEEL +5 -0
- craftkit-0.3.0.dist-info/entry_points.txt +5 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-APACHE +177 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-MIT +21 -0
- craftkit-0.3.0.dist-info/top_level.txt +1 -0
craft_kit/browser.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Photographing pages as a visitor sees them, with Playwright: the Python package when it's
|
|
2
|
+
installed, else the Playwright CLI through npx. Nothing here reads a page's source."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from functools import partial
|
|
12
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterator, Optional
|
|
15
|
+
|
|
16
|
+
from . import proc, tables
|
|
17
|
+
|
|
18
|
+
HEIGHT = 900
|
|
19
|
+
FULL_HEIGHT = 2400 # Chrome photographs the window, not the whole page: make it tall
|
|
20
|
+
INSTALL_HINT = f"Install a browser for screenshots: `pip install playwright && playwright install chromium`, or `npx -y {tables.PLAYWRIGHT} install chromium`."
|
|
21
|
+
|
|
22
|
+
# What a visitor can see, measured in the page (no source is saved).
|
|
23
|
+
FACTS_JS = """() => {
|
|
24
|
+
const pick = (sel) => { const e = document.querySelector(sel); if (!e) return null;
|
|
25
|
+
const s = getComputedStyle(e);
|
|
26
|
+
return {font: s.fontFamily, size: s.fontSize, weight: s.fontWeight, color: s.color, background: s.backgroundColor, radius: s.borderRadius}; };
|
|
27
|
+
const text = (sel, n) => Array.from(document.querySelectorAll(sel)).slice(0, n).map(e => e.innerText.trim()).filter(Boolean);
|
|
28
|
+
return {
|
|
29
|
+
title: document.title,
|
|
30
|
+
width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight,
|
|
31
|
+
styles: {body: pick('body'), h1: pick('h1'), h2: pick('h2'), link: pick('a'), button: pick('button, .btn, [role=button]'), nav: pick('nav, header')},
|
|
32
|
+
headings: text('h1, h2, h3', 60), links: text('nav a, header a', 40), buttons: text('button, [role=button]', 40),
|
|
33
|
+
sections: document.querySelectorAll('section, main > *').length,
|
|
34
|
+
text: document.body ? document.body.innerText.slice(0, 20000) : ''
|
|
35
|
+
};
|
|
36
|
+
}"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
CHROME_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "msedge", "microsoft-edge"]
|
|
40
|
+
CHROME_PATHS = [
|
|
41
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
42
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
43
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
44
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
45
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
46
|
+
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def chrome() -> Optional[str]:
|
|
51
|
+
if os.environ.get("CRAFT_CHROME"):
|
|
52
|
+
return os.environ["CRAFT_CHROME"]
|
|
53
|
+
for n in CHROME_NAMES:
|
|
54
|
+
found = proc.which(n)
|
|
55
|
+
if found:
|
|
56
|
+
return found
|
|
57
|
+
return next((p for p in CHROME_PATHS if os.path.isfile(p)), None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def engine() -> Optional[str]:
|
|
61
|
+
"""Python Playwright (screenshots + visible facts), else an installed Chrome/Edge in headless
|
|
62
|
+
mode (screenshots), else the Playwright CLI through npx (downloads it on first use)."""
|
|
63
|
+
forced = os.environ.get("CRAFT_BROWSER")
|
|
64
|
+
if forced in ("python", "chrome", "npx", "none"):
|
|
65
|
+
return None if forced == "none" else forced
|
|
66
|
+
try:
|
|
67
|
+
import playwright.sync_api # type: ignore # noqa: F401
|
|
68
|
+
|
|
69
|
+
return "python"
|
|
70
|
+
except ImportError:
|
|
71
|
+
pass
|
|
72
|
+
if chrome():
|
|
73
|
+
return "chrome"
|
|
74
|
+
return "npx" if proc.which("npx") else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _chrome_shot(url: str, dest: Path, width: int) -> tuple[bool, str]:
|
|
78
|
+
exe = chrome()
|
|
79
|
+
if not exe:
|
|
80
|
+
return False, "no Chrome or Edge found"
|
|
81
|
+
with tempfile.TemporaryDirectory(prefix="craft-chrome-") as profile: # never the person's own profile
|
|
82
|
+
r = proc.run(
|
|
83
|
+
[exe, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run", "--no-default-browser-check",
|
|
84
|
+
f"--user-data-dir={profile}", f"--window-size={width},{FULL_HEIGHT}", "--virtual-time-budget=4000",
|
|
85
|
+
f"--screenshot={dest}", url],
|
|
86
|
+
timeout=120,
|
|
87
|
+
idle=90,
|
|
88
|
+
)
|
|
89
|
+
return dest.is_file(), r.error or r.tail(3)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def capture(url: str, out: Path, widths: list[int], facts: bool, live=None) -> tuple[list[Path], dict, list[str]]:
|
|
93
|
+
"""Screenshots at each width (screenshot-<w>.png), visitor facts at the first width when the
|
|
94
|
+
Python engine is there, and the problems met, in plain words."""
|
|
95
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
kind = engine()
|
|
97
|
+
if kind is None:
|
|
98
|
+
return [], {}, [f"No browser to photograph pages. {INSTALL_HINT}"]
|
|
99
|
+
if kind == "python":
|
|
100
|
+
return _capture_python(url, out, widths, facts)
|
|
101
|
+
shots, problems = [], []
|
|
102
|
+
if kind == "chrome":
|
|
103
|
+
for w in widths:
|
|
104
|
+
if live:
|
|
105
|
+
live.event("web", f"screenshot at {w} px")
|
|
106
|
+
dest = out / f"screenshot-{w}.png"
|
|
107
|
+
ok, why = _chrome_shot(url, dest, w)
|
|
108
|
+
if ok:
|
|
109
|
+
shots.append(dest)
|
|
110
|
+
else:
|
|
111
|
+
problems.append(f"{w} px: {why}")
|
|
112
|
+
return shots, {}, problems
|
|
113
|
+
for w in widths:
|
|
114
|
+
if live:
|
|
115
|
+
live.event("web", f"screenshot at {w} px")
|
|
116
|
+
dest = out / f"screenshot-{w}.png"
|
|
117
|
+
r = proc.run(
|
|
118
|
+
["npx", "-y", tables.PLAYWRIGHT, "screenshot", "--full-page", f"--viewport-size={w},{HEIGHT}", "--wait-for-timeout=1500", url, str(dest)],
|
|
119
|
+
timeout=240,
|
|
120
|
+
idle=180,
|
|
121
|
+
)
|
|
122
|
+
if r.ok and dest.is_file():
|
|
123
|
+
shots.append(dest)
|
|
124
|
+
else:
|
|
125
|
+
problems.append(f"{w} px: {r.error or r.tail(3)}")
|
|
126
|
+
if problems and not shots:
|
|
127
|
+
problems.append(INSTALL_HINT)
|
|
128
|
+
return shots, {}, problems
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _capture_python(url: str, out: Path, widths: list[int], facts: bool) -> tuple[list[Path], dict, list[str]]:
|
|
132
|
+
from playwright.sync_api import Error, sync_playwright # type: ignore
|
|
133
|
+
|
|
134
|
+
shots: list[Path] = []
|
|
135
|
+
found: dict = {}
|
|
136
|
+
problems: list[str] = []
|
|
137
|
+
try:
|
|
138
|
+
with sync_playwright() as pw:
|
|
139
|
+
browser = pw.chromium.launch()
|
|
140
|
+
try:
|
|
141
|
+
for i, w in enumerate(widths):
|
|
142
|
+
page = browser.new_page(viewport={"width": w, "height": HEIGHT})
|
|
143
|
+
try:
|
|
144
|
+
page.goto(url, wait_until="networkidle", timeout=60_000)
|
|
145
|
+
except Error:
|
|
146
|
+
page.wait_for_timeout(1500) # a page that never goes quiet still has something to see
|
|
147
|
+
page.wait_for_timeout(800)
|
|
148
|
+
dest = out / f"screenshot-{w}.png"
|
|
149
|
+
page.screenshot(path=str(dest), full_page=True)
|
|
150
|
+
shots.append(dest)
|
|
151
|
+
if facts:
|
|
152
|
+
try:
|
|
153
|
+
found[str(w)] = page.evaluate(FACTS_JS) if i == 0 else {k: v for k, v in page.evaluate(FACTS_JS).items() if k in ("width", "height", "styles", "sections")}
|
|
154
|
+
except Error as e:
|
|
155
|
+
problems.append(f"couldn't measure the page at {w} px: {e}")
|
|
156
|
+
page.close()
|
|
157
|
+
finally:
|
|
158
|
+
browser.close()
|
|
159
|
+
except Exception as e: # Playwright raises its own errors, and a missing browser is one of them
|
|
160
|
+
problems.append(f"the browser failed: {str(e).splitlines()[0] if str(e) else type(e).__name__}. {INSTALL_HINT}")
|
|
161
|
+
return shots, found, problems
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _Quiet(SimpleHTTPRequestHandler):
|
|
165
|
+
def log_message(self, *args) -> None: # keep the terminal clean
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@contextmanager
|
|
170
|
+
def serve(folder: Path) -> Iterator[str]:
|
|
171
|
+
"""Serve a folder on loopback for as long as the block runs."""
|
|
172
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(_Quiet, directory=str(folder)))
|
|
173
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
174
|
+
thread.start()
|
|
175
|
+
try:
|
|
176
|
+
yield f"http://127.0.0.1:{server.server_address[1]}/"
|
|
177
|
+
finally:
|
|
178
|
+
server.shutdown()
|
|
179
|
+
server.server_close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def facts_markdown(facts: dict) -> str:
|
|
183
|
+
if not facts:
|
|
184
|
+
return ""
|
|
185
|
+
first = facts.get(next(iter(facts)), {})
|
|
186
|
+
lines = ["## What a visitor sees", ""]
|
|
187
|
+
if first.get("title"):
|
|
188
|
+
lines.append(f"Title: {first['title']}")
|
|
189
|
+
for w, f in facts.items():
|
|
190
|
+
lines.append(f"- {w} px: page {f.get('width')}×{f.get('height')} px, {f.get('sections')} sections")
|
|
191
|
+
styles = first.get("styles") or {}
|
|
192
|
+
if styles:
|
|
193
|
+
lines += ["", "Styles (computed, first width):", "```json", json.dumps(styles, indent=1, ensure_ascii=False), "```"]
|
|
194
|
+
for key, label in (("headings", "Headings"), ("links", "Navigation"), ("buttons", "Buttons")):
|
|
195
|
+
if first.get(key):
|
|
196
|
+
lines.append(f"\n{label}: " + " · ".join(first[key][:30]))
|
|
197
|
+
lines.append("\nThe visible text is in capture.json (`text`).")
|
|
198
|
+
return "\n".join(lines) + "\n"
|
craft_kit/checks.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Check: judge the build against the original, the same way every time, and say what differs.
|
|
2
|
+
|
|
3
|
+
Each check returns {"id", "passed": True|False|None (couldn't judge), "score": percent|None,
|
|
4
|
+
"summary", "claims": [[text, ok, detail]]}. A round is good when no check failed and every score
|
|
5
|
+
reaches the project's good_enough."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Optional
|
|
13
|
+
|
|
14
|
+
from . import browser, png, proc, tables
|
|
15
|
+
from .ui import UI
|
|
16
|
+
from .workspace import Project, read_json, write_atomic
|
|
17
|
+
|
|
18
|
+
BUILD_TIMEOUT = 20 * 60
|
|
19
|
+
ALIKE = 80.0 # a run's output counts as the same when this many of its words match
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def result(cid: str, passed: Optional[bool], summary: str, score: Optional[float] = None, claims: Optional[list] = None) -> dict:
|
|
23
|
+
return {"id": cid, "passed": passed, "score": None if score is None else round(score, 1), "summary": summary, "claims": claims or []}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_build(p: Project, rd: Path, live) -> dict:
|
|
27
|
+
spec = p.app_spec()
|
|
28
|
+
if not any(p.app.iterdir()):
|
|
29
|
+
return result("build", False, "app/ is empty: nothing has been built yet.")
|
|
30
|
+
if "install" in spec and not (p.app / "node_modules").exists():
|
|
31
|
+
live.event("run", "installing dependencies: " + " ".join(spec["install"]))
|
|
32
|
+
r = proc.run(spec["install"], cwd=str(p.app), timeout=BUILD_TIMEOUT, idle=600, on_line=live.update)
|
|
33
|
+
if not r.ok:
|
|
34
|
+
return result("build", False, f"Installing dependencies failed ({r.error or f'exit {r.code}'}):\n{r.tail(25)}")
|
|
35
|
+
if "build" not in spec:
|
|
36
|
+
return result("build", True, "Nothing to build (no build command declared or found).")
|
|
37
|
+
live.event("run", " ".join(spec["build"]))
|
|
38
|
+
r = proc.run(spec["build"], cwd=str(p.app), timeout=BUILD_TIMEOUT, idle=600, on_line=live.update)
|
|
39
|
+
write_atomic(rd / "build.log", r.output)
|
|
40
|
+
if r.ok:
|
|
41
|
+
return result("build", True, f"`{' '.join(spec['build'])}` succeeds.")
|
|
42
|
+
return result("build", False, f"`{' '.join(spec['build'])}` fails ({r.error or f'exit {r.code}'}):\n{r.tail(30)}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_tests(p: Project, rd: Path, live) -> dict:
|
|
46
|
+
spec = p.app_spec()
|
|
47
|
+
if "test" not in spec:
|
|
48
|
+
return result("tests", None, "No tests declared or found.")
|
|
49
|
+
live.event("run", " ".join(spec["test"]))
|
|
50
|
+
r = proc.run(spec["test"], cwd=str(p.app), timeout=BUILD_TIMEOUT, idle=600, on_line=live.update)
|
|
51
|
+
write_atomic(rd / "tests.log", r.output)
|
|
52
|
+
low = r.output.lower()
|
|
53
|
+
if any(s in low for s in tables.NO_TESTS):
|
|
54
|
+
return result("tests", None, "The project has no tests yet.")
|
|
55
|
+
if r.ok:
|
|
56
|
+
return result("tests", True, "The tests pass.")
|
|
57
|
+
return result("tests", False, f"The tests fail ({r.error or f'exit {r.code}'}):\n{r.tail(30)}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def check_web(p: Project, rd: Path, live) -> dict:
|
|
61
|
+
site = p.site_dir()
|
|
62
|
+
if site is None:
|
|
63
|
+
return result("web", False, "No built site found: there must be an index.html in app/dist, app/out, app/build, app/public or app/ (or the folder named by `site` in app/craft-app.json).")
|
|
64
|
+
with browser.serve(site) as url:
|
|
65
|
+
live.event("web", f"photographing the build at {url}")
|
|
66
|
+
shots, _facts, problems = browser.capture(url, rd, tables.WIDTHS, facts=False, live=live)
|
|
67
|
+
if not shots:
|
|
68
|
+
return result("web", None, "The build couldn't be photographed: " + "; ".join(problems))
|
|
69
|
+
claims, scores = [], []
|
|
70
|
+
for shot in shots:
|
|
71
|
+
mine = png.grid(shot)
|
|
72
|
+
if mine is None or mine.blank():
|
|
73
|
+
claims.append([f"At {shot.stem.split('-')[-1]} px, the page shows something", False, "the screenshot is blank"])
|
|
74
|
+
scores.append(0.0)
|
|
75
|
+
continue
|
|
76
|
+
original = p.capture / shot.name
|
|
77
|
+
theirs = png.grid(original) if original.is_file() else None
|
|
78
|
+
if theirs is None:
|
|
79
|
+
claims.append([f"At {shot.stem.split('-')[-1]} px, the page draws", True, "no original to compare with"])
|
|
80
|
+
continue
|
|
81
|
+
s = png.similarity(mine, theirs)
|
|
82
|
+
scores.append(s)
|
|
83
|
+
claims.append([f"At {shot.stem.split('-')[-1]} px, it looks like the original", s >= p.state["good_enough"], f"{s:.0f}% alike", shot.stem.split("-")[-1]])
|
|
84
|
+
score = sum(scores) / len(scores) if scores else None
|
|
85
|
+
if score is None:
|
|
86
|
+
return result("web", True, "The pages draw at every width (nothing to compare them with).", None, claims)
|
|
87
|
+
detail = ", ".join(f"{c[3]} px {c[2]}" for c in claims if len(c) > 3)
|
|
88
|
+
claims = [c[:3] for c in claims]
|
|
89
|
+
return result("web", all(c[1] for c in claims), f"Looks {score:.0f}% like the original ({detail}). Your screenshots are in {rd.relative_to(p.root)}/, the original's in capture/.", score, claims)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_spec(p: Project) -> Optional[list]:
|
|
93
|
+
run = p.app_spec().get("run")
|
|
94
|
+
return list(run) if run else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _word_alike(a: str, b: str) -> float:
|
|
98
|
+
wa, wb = set(a.lower().split()), set(b.lower().split())
|
|
99
|
+
if not wa and not wb:
|
|
100
|
+
return 100.0
|
|
101
|
+
return 100.0 * len(wa & wb) / max(1, len(wa | wb))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_runs(p: Project, rd: Path, live) -> dict:
|
|
105
|
+
recorded = read_json(p.capture / "runs.json", {})
|
|
106
|
+
runs = recorded.get("runs") if isinstance(recorded, dict) else None
|
|
107
|
+
if not runs:
|
|
108
|
+
return result("runs", None, "No runs of the original were recorded (start with --allow-run on a program to record them).")
|
|
109
|
+
cmd = _run_spec(p)
|
|
110
|
+
if not cmd:
|
|
111
|
+
return result("runs", False, 'app/craft-app.json has no "run" command, so the rebuild can\'t be run with the same inputs.')
|
|
112
|
+
claims, scores, mine = [], [], []
|
|
113
|
+
for run in runs:
|
|
114
|
+
args = [a for a in run.get("args", []) if isinstance(a, str)]
|
|
115
|
+
live.event("run", " ".join(args) or "(no arguments)")
|
|
116
|
+
r = proc.run([*cmd, *args], cwd=str(p.app), timeout=15, cap=16_000)
|
|
117
|
+
mine.append({"args": args, "code": r.code, "output": r.output, "error": r.error})
|
|
118
|
+
label = " ".join(args) or "no arguments"
|
|
119
|
+
claims.append([f"With {label}, it exits with code {run.get('code')}", r.code == run.get("code"), f"it exits with {r.code}" + (f" ({r.error})" if r.error else "")])
|
|
120
|
+
s = _word_alike(r.output, run.get("output", ""))
|
|
121
|
+
scores.append(s)
|
|
122
|
+
claims.append([f"With {label}, it prints what the original prints", s >= ALIKE, f"{s:.0f}% of the words match"])
|
|
123
|
+
write_atomic(rd / "runs.json", json.dumps({"version": 1, "runs": mine}, indent=1, ensure_ascii=False))
|
|
124
|
+
score = sum(scores) / len(scores)
|
|
125
|
+
bad = [c for c in claims if not c[1]]
|
|
126
|
+
return result("runs", not bad, f"{len(claims) - len(bad)} of {len(claims)} claims hold; output {score:.0f}% alike.", score, claims)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def check_screenshot(p: Project, rd: Path, live) -> dict:
|
|
130
|
+
cmd = _run_spec(p)
|
|
131
|
+
if not cmd:
|
|
132
|
+
return result("screenshot", False, 'app/craft-app.json has no "run" command, so the program can\'t be started.')
|
|
133
|
+
dest = rd / "screenshot.png"
|
|
134
|
+
live.event("run", "starting it with --screenshot")
|
|
135
|
+
r = proc.run([*cmd, "--screenshot", str(dest)], cwd=str(p.app), timeout=90, idle=60)
|
|
136
|
+
g = png.grid(dest) if dest.is_file() else None
|
|
137
|
+
if g is None:
|
|
138
|
+
return result("screenshot", False, f"The program didn't save a readable PNG with --screenshot ({r.error or f'exit {r.code}'}). Contract: start, draw about two seconds of frames, save the window to that file, exit.")
|
|
139
|
+
if g.blank():
|
|
140
|
+
return result("screenshot", False, "The --screenshot picture is one colour: nothing was drawn before saving.")
|
|
141
|
+
refs = [x for x in sorted(p.reference.glob("*.png"))] if p.reference.exists() else []
|
|
142
|
+
best = None
|
|
143
|
+
for ref in refs:
|
|
144
|
+
rg = png.grid(ref)
|
|
145
|
+
if rg is not None:
|
|
146
|
+
s = png.similarity(g, rg)
|
|
147
|
+
best = s if best is None else max(best, s)
|
|
148
|
+
if best is None:
|
|
149
|
+
return result("screenshot", True, f"It starts and draws ({g.width}×{g.height}).")
|
|
150
|
+
return result("screenshot", True, f"It starts and draws ({g.width}×{g.height}); about {best:.0f}% like the closest picture in reference/.", best)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def check_selftest(p: Project, rd: Path, live) -> dict:
|
|
154
|
+
cmd = _run_spec(p)
|
|
155
|
+
if not cmd:
|
|
156
|
+
return result("selftest", False, 'app/craft-app.json has no "run" command, so the game can\'t be started.')
|
|
157
|
+
dest = rd / "self-test.json"
|
|
158
|
+
live.event("run", "playing the core loop with --self-test")
|
|
159
|
+
r = proc.run([*cmd, "--self-test", str(dest)], cwd=str(p.app), timeout=120, idle=90)
|
|
160
|
+
report = read_json(dest)
|
|
161
|
+
if not isinstance(report, dict) or not isinstance(report.get("steps"), list):
|
|
162
|
+
return result("selftest", False, f"The game didn't save a report with --self-test ({r.error or f'exit {r.code}'}). Contract: {{\"version\": 1, \"steps\": [{{\"step\": \"move\", \"passed\": true, \"detail\": \"…\"}}]}} with steps move, act and hud measured from the game's own state.")
|
|
163
|
+
claims = [[str(s.get("step")), bool(s.get("passed")), str(s.get("detail", ""))] for s in report["steps"] if isinstance(s, dict)]
|
|
164
|
+
have = {c[0] for c in claims}
|
|
165
|
+
for need in ("move", "act", "hud"):
|
|
166
|
+
if need not in have:
|
|
167
|
+
claims.append([need, False, "not measured"])
|
|
168
|
+
bad = [c for c in claims if not c[1]]
|
|
169
|
+
return result("selftest", not bad, "The core loop works." if not bad else f"{len(bad)} of {len(claims)} steps don't pass: " + ", ".join(c[0] for c in bad), None, claims)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
CHECKS: dict[str, Callable] = {"build": check_build, "tests": check_tests, "web": check_web, "runs": check_runs, "screenshot": check_screenshot, "selftest": check_selftest}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def good(res: dict, good_enough: float) -> bool:
|
|
176
|
+
return all(c["passed"] is not False and (c["score"] is None or c["score"] >= good_enough) for c in res["checks"])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run_checks(p: Project, round_no: int, ui: UI) -> dict:
|
|
180
|
+
rd = p.round_dir(round_no)
|
|
181
|
+
rd.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
out = []
|
|
183
|
+
ids = tables.KINDS.get(p.state["kind"], tables.KINDS["tool"])["checks"]
|
|
184
|
+
for cid in ids:
|
|
185
|
+
with ui.live(f"check {cid}", "check") as live:
|
|
186
|
+
try:
|
|
187
|
+
res = CHECKS[cid](p, rd, live)
|
|
188
|
+
except Exception as e: # a check that breaks says so; it never stops the run
|
|
189
|
+
res = result(cid, None, f"The check itself failed: {type(e).__name__}: {e}")
|
|
190
|
+
out.append(res)
|
|
191
|
+
if cid == "build" and res["passed"] is False:
|
|
192
|
+
break # nothing else can be judged on a build that doesn't build
|
|
193
|
+
res = {"version": 1, "round": round_no, "time": time.strftime("%Y-%m-%dT%H:%M:%S"), "checks": out}
|
|
194
|
+
res["good"] = good(res, p.state["good_enough"])
|
|
195
|
+
write_atomic(rd / "result.json", json.dumps(res, indent=1, ensure_ascii=False))
|
|
196
|
+
p.log(f"check round {round_no}: {'good' if res['good'] else 'not yet'} " + "; ".join(f"{c['id']}={c['passed']}" for c in out))
|
|
197
|
+
return res
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def show(res: dict, ui: UI) -> None:
|
|
201
|
+
for c in res["checks"]:
|
|
202
|
+
state = "skip" if c["passed"] is None else ("done" if c["passed"] else "fail")
|
|
203
|
+
first, *rest = c["summary"].splitlines() or [""]
|
|
204
|
+
ui.step(state, c["id"], first, stage="check")
|
|
205
|
+
if c["passed"] is False and rest:
|
|
206
|
+
ui.note("\n".join(rest[-12:]))
|
|
207
|
+
for text, ok, detail in c["claims"]:
|
|
208
|
+
if not ok:
|
|
209
|
+
ui.note(f"{text}: {detail}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def refine_text(res: dict) -> str:
|
|
213
|
+
"""What the builder is told after a round that isn't good yet."""
|
|
214
|
+
lines = [f"Check {res['round']} isn't passing yet:"]
|
|
215
|
+
for c in res["checks"]:
|
|
216
|
+
if c["passed"] is False or (c["score"] is not None and not res["good"]):
|
|
217
|
+
lines.append(f"\n[{c['id']}] {c['summary']}")
|
|
218
|
+
lines += [f"- {t}: {d}" for t, ok, d in c["claims"] if not ok]
|
|
219
|
+
return "\n".join(lines)
|