ltcai 10.6.2 → 10.7.0
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.
- package/README.md +44 -59
- package/docs/CHANGELOG.md +56 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/LAYOUT_REBUILD_SPEC.md +810 -0
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +29 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +72 -0
- package/latticeai/api/automation_intelligence.py +112 -0
- package/latticeai/api/knowledge_graph.py +133 -0
- package/latticeai/api/static_routes.py +35 -1
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +3 -0
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/core/workspace_runs.py +111 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/package.json +5 -3
- package/scripts/capture_release_evidence.mjs +335 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_release_evidence_bound.mjs +116 -0
- package/scripts/check_screenshot_pixel_delta.py +435 -0
- package/scripts/measure_brain_home_fill.mjs +141 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -39
- package/static/app/assets/{Act-aNud-lKL.js → Act-DIbkoJqs.js} +1 -1
- package/static/app/assets/AdminConsole-Cbi6Jre6.js +1 -0
- package/static/app/assets/{Brain-CMFh5q6k.js → Brain-BPfuXZ71.js} +2 -2
- package/static/app/assets/BrainHome-87XaAS4V.js +2 -0
- package/static/app/assets/{BrainSignals-RVMlmTGC.js → BrainSignals-ExpcdiUi.js} +1 -1
- package/static/app/assets/Capture-Bxe4WiVX.js +1 -0
- package/static/app/assets/{CommandPalette-bW5WxWWZ.js → CommandPalette-B1PV5Yd4.js} +1 -1
- package/static/app/assets/Library-BRlJJmwZ.js +1 -0
- package/static/app/assets/{LivingBrain-FlHFfu9i.js → LivingBrain-D61qG3WY.js} +1 -1
- package/static/app/assets/ProductFlow-BxjxI8Aa.js +1 -0
- package/static/app/assets/{ReviewCard-BHj86h2Z.js → ReviewCard-BoiWv0Y1.js} +2 -2
- package/static/app/assets/System-Ci0HEtTJ.js +1 -0
- package/static/app/assets/arrow-left-DArdBUxd.js +1 -0
- package/static/app/assets/{bot-D1gX4xks.js → bot-BaPt4PPn.js} +1 -1
- package/static/app/assets/brain-BSkWaj3i.js +1 -0
- package/static/app/assets/{button-DPFZ9lGw.js → button-CKwtO1O0.js} +1 -1
- package/static/app/assets/{circle-pause-B2P72YOO.js → circle-pause-CiUc5RRk.js} +1 -1
- package/static/app/assets/{circle-play-BiC8z2vg.js → circle-play-BaTDxzR9.js} +1 -1
- package/static/app/assets/{cpu-Cx-wRR_V.js → cpu-Dm1gHuP1.js} +1 -1
- package/static/app/assets/{download-CKxlzxsK.js → download-C32oGU-C.js} +1 -1
- package/static/app/assets/{folder-open-Bbjju0tt.js → folder-open-Ch2jLVdD.js} +1 -1
- package/static/app/assets/{hard-drive-Bon1VBvq.js → hard-drive-BBjA5st8.js} +1 -1
- package/static/app/assets/index-BV7Yf6Eq.css +2 -0
- package/static/app/assets/{index-mLP0-YNO.js → index-J6h01X78.js} +4 -4
- package/static/app/assets/{input-DrMc0Xns.js → input-y6ABdKWY.js} +1 -1
- package/static/app/assets/{permissionCopy-BGUsI7vw.js → permissionCopy-BEtc0Ihd.js} +1 -1
- package/static/app/assets/{primitives-CEMTjBz1.js → primitives-GSFXF2Pd.js} +1 -1
- package/static/app/assets/search-CaIZKyo_.js +1 -0
- package/static/app/assets/{share-2-D7THHq5K.js → share-2-BgGq2A63.js} +1 -1
- package/static/app/assets/{shield-alert-BoU4_8r9.js → shield-alert-BYA491cj.js} +1 -1
- package/static/app/assets/{textarea-rU1Lb7ia.js → textarea-BAcim3fs.js} +1 -1
- package/static/app/assets/{useFocusTrap-BgvK4Nkx.js → useFocusTrap-qLg24jiv.js} +1 -1
- package/static/app/assets/{useQuery-CT2ChyuU.js → useQuery-xkAu9eDX.js} +1 -1
- package/static/app/assets/utils-CogdofAA.js +4 -0
- package/static/app/assets/workspace-BMvfx9_R.js +1 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/AdminConsole-QiCTH68K.js +0 -1
- package/static/app/assets/BrainHome-B4Ar3atu.js +0 -2
- package/static/app/assets/Capture-DEBo-0vZ.js +0 -1
- package/static/app/assets/Library-5gFexm83.js +0 -1
- package/static/app/assets/ProductFlow-CFUNDOHu.js +0 -1
- package/static/app/assets/System-CeNHoZRu.js +0 -1
- package/static/app/assets/activity-7_ZmZqN0.js +0 -1
- package/static/app/assets/arrow-left-Dv2Tiwhe.js +0 -1
- package/static/app/assets/brain-Dn_bDfl4.js +0 -1
- package/static/app/assets/index-DYUs0cWy.css +0 -2
- package/static/app/assets/search-HsIji1wY.js +0 -1
- package/static/app/assets/users-DlbfHBQV.js +0 -1
- package/static/app/assets/utils-fEGWreKB.js +0 -7
- package/static/app/assets/workspace-ClDBz_0f.js +0 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compare release screenshots against a prior release and fail on no-change.
|
|
3
|
+
|
|
4
|
+
Proves the layout rebuild actually moved pixels (or height), not just copy.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
python scripts/check_screenshot_pixel_delta.py \\
|
|
8
|
+
--current output/release/v10.6.3/screenshots \\
|
|
9
|
+
--baseline output/release/v10.6.2/screenshots
|
|
10
|
+
|
|
11
|
+
Environment:
|
|
12
|
+
LTCAI_SCREENSHOT_CURRENT default: output/release/v{package}/screenshots
|
|
13
|
+
LTCAI_SCREENSHOT_BASELINE explicit baseline dir (must match expected version)
|
|
14
|
+
LTCAI_SCREENSHOT_BASELINE_VERSION pin expected baseline, e.g. 10.6.2 (default: prior git tag)
|
|
15
|
+
LTCAI_PIXEL_MIN_PCT default: 1.5 (non-LivingBrain screens)
|
|
16
|
+
LTCAI_PIXEL_MIN_PCT_LIVE default: 8.0 (01/03/04 — animation alone ~7.9%)
|
|
17
|
+
LTCAI_HEIGHT_MIN_PX default: 24 (layout height shift counts as change)
|
|
18
|
+
|
|
19
|
+
Exit 0 all screens moved enough, 1 one or more below threshold / wrong baseline, 2 bad args.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Dict, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from PIL import Image, ImageChops
|
|
34
|
+
|
|
35
|
+
SHOTS = [
|
|
36
|
+
"01-login.png",
|
|
37
|
+
"02-recommended-models.png",
|
|
38
|
+
"03-install-load-progress.png",
|
|
39
|
+
"04-brain-chat-home.png",
|
|
40
|
+
"05-memory-graph.png",
|
|
41
|
+
"06-capture.png",
|
|
42
|
+
"07-model-library.png",
|
|
43
|
+
"08-system.png",
|
|
44
|
+
"09-automation-runs.png",
|
|
45
|
+
"10-admin-console.png",
|
|
46
|
+
"11-knowledge-journey.png",
|
|
47
|
+
"12-review-center.png",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# LivingBrain animation noise can hit ~7.9% with zero layout change.
|
|
51
|
+
LIVING_BRAIN = {
|
|
52
|
+
"01-login.png",
|
|
53
|
+
"03-install-load-progress.png",
|
|
54
|
+
"04-brain-chat-home.png",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _repo_root() -> Path:
|
|
59
|
+
return Path(__file__).resolve().parents[1]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _package_version(root: Path) -> str:
|
|
63
|
+
package = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
64
|
+
return str(package["version"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _default_current(root: Path) -> Path:
|
|
68
|
+
env = os.environ.get("LTCAI_SCREENSHOT_CURRENT")
|
|
69
|
+
if env:
|
|
70
|
+
return Path(env)
|
|
71
|
+
version = _package_version(root)
|
|
72
|
+
return root / "output" / "release" / f"v{version}" / "screenshots"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _version_key(name: str) -> Tuple[int, ...]:
|
|
76
|
+
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", name)
|
|
77
|
+
if not m:
|
|
78
|
+
return (0, 0, 0)
|
|
79
|
+
return tuple(int(p) for p in m.groups())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _normalize_version(name: str) -> str:
|
|
83
|
+
return name[1:] if name.startswith("v") else name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _git_version_tags(root: Path) -> List[str]:
|
|
87
|
+
"""Return sorted semver tags like ``10.6.2`` (no leading v) from the repo."""
|
|
88
|
+
try:
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["git", "tag", "-l", "v*.*.*"],
|
|
91
|
+
cwd=root,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
text=True,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
except OSError:
|
|
97
|
+
return []
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
return []
|
|
100
|
+
tags: List[Tuple[Tuple[int, ...], str]] = []
|
|
101
|
+
for line in result.stdout.splitlines():
|
|
102
|
+
name = line.strip()
|
|
103
|
+
if not name:
|
|
104
|
+
continue
|
|
105
|
+
key = _version_key(name)
|
|
106
|
+
if key == (0, 0, 0):
|
|
107
|
+
continue
|
|
108
|
+
tags.append((key, _normalize_version(name)))
|
|
109
|
+
tags.sort(key=lambda item: item[0])
|
|
110
|
+
return [version for _, version in tags]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def expected_baseline_version(root: Path, current_version: Optional[str] = None) -> str:
|
|
114
|
+
"""Pin the baseline to a release tag — never "any older dir".
|
|
115
|
+
|
|
116
|
+
Resolution order:
|
|
117
|
+
1. ``LTCAI_SCREENSHOT_BASELINE_VERSION`` (explicit pin)
|
|
118
|
+
2. Git tag whose name matches the package version (same-name tag)
|
|
119
|
+
— used when the version was not bumped after a release. Comparing
|
|
120
|
+
against the prior tag (e.g. 10.6.2 while package is still 10.6.3 and
|
|
121
|
+
``v10.6.3`` is tagged) lets screens that are identical to the already
|
|
122
|
+
shipped release pass, because they still differ from two-releases-ago.
|
|
123
|
+
Same-name baseline forces a real delta vs the published screenshots
|
|
124
|
+
(materialized via ``git show`` when the on-disk dir is missing/stale).
|
|
125
|
+
3. Highest git tag strictly older than the package version
|
|
126
|
+
4. Patch-1 of the package version (last resort when tags are unavailable)
|
|
127
|
+
|
|
128
|
+
This also prevents a silent downgrade to v10.6.1 when v10.6.2 is deleted
|
|
129
|
+
from ``output/release/`` — which would inflate the pixel-change rate.
|
|
130
|
+
"""
|
|
131
|
+
env = os.environ.get("LTCAI_SCREENSHOT_BASELINE_VERSION")
|
|
132
|
+
if env:
|
|
133
|
+
return _normalize_version(env.strip())
|
|
134
|
+
|
|
135
|
+
current = _normalize_version(current_version or _package_version(root))
|
|
136
|
+
tags = _git_version_tags(root)
|
|
137
|
+
# Same-name tag = this version already shipped. Baseline is that release,
|
|
138
|
+
# not the prior one (failure mode: unbumped recapture vs two-releases-ago).
|
|
139
|
+
if current in tags:
|
|
140
|
+
return current
|
|
141
|
+
|
|
142
|
+
cur_key = _version_key(current)
|
|
143
|
+
older = [v for v in tags if _version_key(v) < cur_key]
|
|
144
|
+
if older:
|
|
145
|
+
return older[-1]
|
|
146
|
+
|
|
147
|
+
major, minor, patch = cur_key
|
|
148
|
+
if patch > 0:
|
|
149
|
+
return f"{major}.{minor}.{patch - 1}"
|
|
150
|
+
if minor > 0:
|
|
151
|
+
return f"{major}.{minor - 1}.0"
|
|
152
|
+
if major > 0:
|
|
153
|
+
return f"{major - 1}.0.0"
|
|
154
|
+
return current
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _baseline_dir_for_version(root: Path, version: str) -> Path:
|
|
158
|
+
return root / "output" / "release" / f"v{_normalize_version(version)}" / "screenshots"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _baseline_looks_complete(shots: Path) -> bool:
|
|
162
|
+
return shots.is_dir() and (shots / "01-login.png").is_file()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _extract_baseline_from_git(root: Path, version: str) -> Optional[Path]:
|
|
166
|
+
"""Materialize ``v{version}`` release screenshots from the matching git tag.
|
|
167
|
+
|
|
168
|
+
Used when the working tree no longer has ``output/release/vX.Y.Z`` but the
|
|
169
|
+
tag still carries the evidence (so deleting a local dir cannot silently
|
|
170
|
+
fall back to an older on-disk release).
|
|
171
|
+
"""
|
|
172
|
+
version = _normalize_version(version)
|
|
173
|
+
tag = f"v{version}"
|
|
174
|
+
probe = subprocess.run(
|
|
175
|
+
["git", "rev-parse", "--verify", f"{tag}^{{commit}}"],
|
|
176
|
+
cwd=root,
|
|
177
|
+
capture_output=True,
|
|
178
|
+
text=True,
|
|
179
|
+
check=False,
|
|
180
|
+
)
|
|
181
|
+
if probe.returncode != 0:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
cache = root / "output" / ".cache" / "screenshot-baseline" / f"v{version}" / "screenshots"
|
|
185
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
186
|
+
extracted = 0
|
|
187
|
+
for name in SHOTS:
|
|
188
|
+
dest = cache / name
|
|
189
|
+
if dest.is_file() and dest.stat().st_size > 0:
|
|
190
|
+
extracted += 1
|
|
191
|
+
continue
|
|
192
|
+
git_path = f"output/release/v{version}/screenshots/{name}"
|
|
193
|
+
result = subprocess.run(
|
|
194
|
+
["git", "show", f"{tag}:{git_path}"],
|
|
195
|
+
cwd=root,
|
|
196
|
+
capture_output=True,
|
|
197
|
+
check=False,
|
|
198
|
+
)
|
|
199
|
+
if result.returncode != 0 or not result.stdout:
|
|
200
|
+
continue
|
|
201
|
+
dest.write_bytes(result.stdout)
|
|
202
|
+
extracted += 1
|
|
203
|
+
if extracted < len(SHOTS):
|
|
204
|
+
return None
|
|
205
|
+
return cache
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def resolve_baseline(
|
|
209
|
+
root: Path,
|
|
210
|
+
current: Path,
|
|
211
|
+
*,
|
|
212
|
+
explicit: Optional[Path] = None,
|
|
213
|
+
) -> Tuple[Optional[Path], str]:
|
|
214
|
+
"""Return ``(baseline_dir_or_None, expected_version)``.
|
|
215
|
+
|
|
216
|
+
Never substitutes an older-than-expected on-disk release. Callers must fail
|
|
217
|
+
when the path is None.
|
|
218
|
+
|
|
219
|
+
When the baseline version equals the package version (same-name tag path),
|
|
220
|
+
prefer screenshots materialized from the git tag over
|
|
221
|
+
``output/release/vX.Y.Z/`` — that on-disk dir is where capture writes, so
|
|
222
|
+
it may already hold WIP overwrites of the published release.
|
|
223
|
+
"""
|
|
224
|
+
expected = expected_baseline_version(root)
|
|
225
|
+
print(f"pixel delta: expected baseline version = v{expected}")
|
|
226
|
+
|
|
227
|
+
if explicit is not None:
|
|
228
|
+
return explicit.resolve(), expected
|
|
229
|
+
|
|
230
|
+
env = os.environ.get("LTCAI_SCREENSHOT_BASELINE")
|
|
231
|
+
if env:
|
|
232
|
+
path = Path(env).resolve()
|
|
233
|
+
# If the path embeds a version segment, it must match the pin.
|
|
234
|
+
parent_name = path.parent.name if path.name == "screenshots" else path.name
|
|
235
|
+
if parent_name.startswith("v") and _normalize_version(parent_name) != expected:
|
|
236
|
+
print(
|
|
237
|
+
f"pixel delta: LTCAI_SCREENSHOT_BASELINE points at {parent_name}, "
|
|
238
|
+
f"expected v{expected}",
|
|
239
|
+
file=sys.stderr,
|
|
240
|
+
)
|
|
241
|
+
return None, expected
|
|
242
|
+
return path, expected
|
|
243
|
+
|
|
244
|
+
package = _normalize_version(_package_version(root))
|
|
245
|
+
same_name_as_package = _normalize_version(expected) == package
|
|
246
|
+
|
|
247
|
+
# Same-name baseline: always take the tagged release via git show.
|
|
248
|
+
# Local output/release/v{package}/ is the capture target and may already
|
|
249
|
+
# contain unreleased UI (exactly the failure mode this pin prevents).
|
|
250
|
+
if same_name_as_package:
|
|
251
|
+
extracted = _extract_baseline_from_git(root, expected)
|
|
252
|
+
if extracted is not None and _baseline_looks_complete(extracted):
|
|
253
|
+
print(
|
|
254
|
+
f"pixel delta: same-name tag v{expected} — "
|
|
255
|
+
f"materialized baseline from git (not on-disk output/release/)"
|
|
256
|
+
)
|
|
257
|
+
return extracted.resolve(), expected
|
|
258
|
+
print(
|
|
259
|
+
f"pixel delta: same-name tag v{expected} but git materialization failed",
|
|
260
|
+
file=sys.stderr,
|
|
261
|
+
)
|
|
262
|
+
return None, expected
|
|
263
|
+
|
|
264
|
+
local = _baseline_dir_for_version(root, expected)
|
|
265
|
+
if _baseline_looks_complete(local):
|
|
266
|
+
return local.resolve(), expected
|
|
267
|
+
|
|
268
|
+
extracted = _extract_baseline_from_git(root, expected)
|
|
269
|
+
if extracted is not None and _baseline_looks_complete(extracted):
|
|
270
|
+
print(f"pixel delta: materialized baseline from git tag v{expected}")
|
|
271
|
+
return extracted.resolve(), expected
|
|
272
|
+
|
|
273
|
+
return None, expected
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _default_baseline(root: Path, current: Path) -> Optional[Path]:
|
|
277
|
+
"""Backward-compatible helper: path only (no version). Prefer resolve_baseline."""
|
|
278
|
+
path, _version = resolve_baseline(root, current)
|
|
279
|
+
return path
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _pixel_delta_pct(a: Path, b: Path) -> Tuple[float, int, int, int, int]:
|
|
283
|
+
"""Return (changed_pct, changed_px, total_px, height_a, height_b)."""
|
|
284
|
+
with Image.open(a) as im_a, Image.open(b) as im_b:
|
|
285
|
+
im_a = im_a.convert("RGB")
|
|
286
|
+
im_b = im_b.convert("RGB")
|
|
287
|
+
h_a, h_b = im_a.size[1], im_b.size[1]
|
|
288
|
+
# Align to common box so pure vertical growth still registers.
|
|
289
|
+
w = max(im_a.size[0], im_b.size[0])
|
|
290
|
+
h = max(h_a, h_b)
|
|
291
|
+
canvas_a = Image.new("RGB", (w, h), (0, 0, 0))
|
|
292
|
+
canvas_b = Image.new("RGB", (w, h), (0, 0, 0))
|
|
293
|
+
canvas_a.paste(im_a, (0, 0))
|
|
294
|
+
canvas_b.paste(im_b, (0, 0))
|
|
295
|
+
diff = ImageChops.difference(canvas_a, canvas_b)
|
|
296
|
+
# Any channel delta > 8 counts as changed (ignore sub-pixel AA noise).
|
|
297
|
+
# Count on RGB getdata: a pixel is changed if any channel exceeds the
|
|
298
|
+
# threshold. (A point()+convert("L") mask would average channels and
|
|
299
|
+
# under-count single-channel diffs, so we do not use it.)
|
|
300
|
+
pixels = list(diff.getdata())
|
|
301
|
+
changed = sum(1 for r, g, b in pixels if r > 8 or g > 8 or b > 8)
|
|
302
|
+
total = w * h
|
|
303
|
+
pct = (100.0 * changed / total) if total else 0.0
|
|
304
|
+
return pct, changed, total, h_a, h_b
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
308
|
+
root = _repo_root()
|
|
309
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
310
|
+
parser.add_argument("--current", type=Path, default=_default_current(root))
|
|
311
|
+
parser.add_argument("--baseline", type=Path, default=None)
|
|
312
|
+
parser.add_argument(
|
|
313
|
+
"--expect-baseline-version",
|
|
314
|
+
type=str,
|
|
315
|
+
default=None,
|
|
316
|
+
help="Override expected baseline version pin (same as LTCAI_SCREENSHOT_BASELINE_VERSION)",
|
|
317
|
+
)
|
|
318
|
+
parser.add_argument(
|
|
319
|
+
"--min-pct",
|
|
320
|
+
type=float,
|
|
321
|
+
default=float(os.environ.get("LTCAI_PIXEL_MIN_PCT", "1.5")),
|
|
322
|
+
help="Minimum pixel change %% for non-LivingBrain screens",
|
|
323
|
+
)
|
|
324
|
+
parser.add_argument(
|
|
325
|
+
"--min-pct-live",
|
|
326
|
+
type=float,
|
|
327
|
+
default=float(os.environ.get("LTCAI_PIXEL_MIN_PCT_LIVE", "8.0")),
|
|
328
|
+
help="Minimum pixel change %% for LivingBrain screens (above ~7.9%% noise)",
|
|
329
|
+
)
|
|
330
|
+
parser.add_argument(
|
|
331
|
+
"--min-height-px",
|
|
332
|
+
type=int,
|
|
333
|
+
default=int(os.environ.get("LTCAI_HEIGHT_MIN_PX", "24")),
|
|
334
|
+
help="Height delta (px) that counts as a layout change even if pixel %% is low",
|
|
335
|
+
)
|
|
336
|
+
args = parser.parse_args(argv)
|
|
337
|
+
|
|
338
|
+
if args.expect_baseline_version:
|
|
339
|
+
os.environ["LTCAI_SCREENSHOT_BASELINE_VERSION"] = _normalize_version(
|
|
340
|
+
args.expect_baseline_version
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
current = args.current.resolve()
|
|
344
|
+
baseline, expected_version = resolve_baseline(
|
|
345
|
+
root, current, explicit=args.baseline
|
|
346
|
+
)
|
|
347
|
+
print(f"pixel delta: current = {current}")
|
|
348
|
+
print(f"pixel delta: baseline = {baseline}")
|
|
349
|
+
print(f"pixel delta: baseline version pin = v{expected_version}")
|
|
350
|
+
|
|
351
|
+
if not current.is_dir():
|
|
352
|
+
print(f"pixel delta: current dir missing: {current}", file=sys.stderr)
|
|
353
|
+
return 2
|
|
354
|
+
if baseline is None or not baseline.is_dir():
|
|
355
|
+
print(
|
|
356
|
+
f"pixel delta: FAIL — expected baseline v{expected_version} is missing "
|
|
357
|
+
f"(will not fall back to an older release). "
|
|
358
|
+
f"Restore output/release/v{expected_version}/screenshots or the git tag.",
|
|
359
|
+
file=sys.stderr,
|
|
360
|
+
)
|
|
361
|
+
return 1
|
|
362
|
+
|
|
363
|
+
# Final pin check: if baseline path embeds a version, it must match.
|
|
364
|
+
parent_name = baseline.parent.name if baseline.name == "screenshots" else baseline.name
|
|
365
|
+
if parent_name.startswith("v") and _normalize_version(parent_name) != expected_version:
|
|
366
|
+
print(
|
|
367
|
+
f"pixel delta: FAIL — baseline path version {parent_name} "
|
|
368
|
+
f"!= expected v{expected_version}",
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
return 1
|
|
372
|
+
|
|
373
|
+
print(
|
|
374
|
+
f"pixel delta: thresholds static>={args.min_pct:.1f}% "
|
|
375
|
+
f"live>={args.min_pct_live:.1f}% height>={args.min_height_px}px"
|
|
376
|
+
)
|
|
377
|
+
print(
|
|
378
|
+
f"{'file':<32} {'px%':>8} {'Δh':>6} {'min%':>6} {'pass':>5}"
|
|
379
|
+
)
|
|
380
|
+
print("-" * 64)
|
|
381
|
+
|
|
382
|
+
failures: List[str] = []
|
|
383
|
+
results: List[Dict[str, object]] = []
|
|
384
|
+
|
|
385
|
+
for name in SHOTS:
|
|
386
|
+
cur = current / name
|
|
387
|
+
base = baseline / name
|
|
388
|
+
if not cur.is_file():
|
|
389
|
+
failures.append(f"{name}: missing in current")
|
|
390
|
+
print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
|
|
391
|
+
continue
|
|
392
|
+
if not base.is_file():
|
|
393
|
+
failures.append(f"{name}: missing in baseline")
|
|
394
|
+
print(f"{name:<32} {'—':>8} {'—':>6} {'—':>6} FAIL")
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
pct, changed, total, h_cur, h_base = _pixel_delta_pct(cur, base)
|
|
398
|
+
height_delta = abs(h_cur - h_base)
|
|
399
|
+
live = name in LIVING_BRAIN
|
|
400
|
+
min_pct = args.min_pct_live if live else args.min_pct
|
|
401
|
+
# Height growth alone proves layout rebuild for full-page shots.
|
|
402
|
+
ok = pct >= min_pct or height_delta >= args.min_height_px
|
|
403
|
+
tag = "ok" if ok else "FAIL"
|
|
404
|
+
if not ok:
|
|
405
|
+
failures.append(
|
|
406
|
+
f"{name}: pixel={pct:.2f}% (need>={min_pct:.1f}%) "
|
|
407
|
+
f"height_delta={height_delta}px (need>={args.min_height_px})"
|
|
408
|
+
)
|
|
409
|
+
print(f"{name:<32} {pct:7.2f}% {height_delta:5d}p {min_pct:5.1f}% {tag:>5}")
|
|
410
|
+
results.append(
|
|
411
|
+
{
|
|
412
|
+
"file": name,
|
|
413
|
+
"pixel_pct": round(pct, 3),
|
|
414
|
+
"height_current": h_cur,
|
|
415
|
+
"height_baseline": h_base,
|
|
416
|
+
"height_delta": height_delta,
|
|
417
|
+
"min_pct": min_pct,
|
|
418
|
+
"living_brain": live,
|
|
419
|
+
"pass": ok,
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
print("-" * 64)
|
|
424
|
+
if failures:
|
|
425
|
+
print(f"pixel delta: FAIL — {len(failures)}/{len(SHOTS)} screen(s) below threshold")
|
|
426
|
+
for item in failures:
|
|
427
|
+
print(f" - {item}", file=sys.stderr)
|
|
428
|
+
return 1
|
|
429
|
+
|
|
430
|
+
print(f"pixel delta: PASS — all {len(SHOTS)} screens moved enough vs baseline v{expected_version}")
|
|
431
|
+
return 0
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == "__main__":
|
|
435
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Measure Brain chat home (screen 04) content fill against the viewport.
|
|
4
|
+
*
|
|
5
|
+
* The layout rebuild requires the empty home's content bottom to reach at
|
|
6
|
+
* least 90% of the 920px capture viewport — not "looks better" prose.
|
|
7
|
+
*
|
|
8
|
+
* Walks the same onboarding path as capture_release_evidence.mjs so #/brain
|
|
9
|
+
* is the empty first-run home against the visual mock server.
|
|
10
|
+
*
|
|
11
|
+
* Exit 0 when bottom/viewport >= --min-ratio (default 0.90), else 1.
|
|
12
|
+
*/
|
|
13
|
+
import { chromium } from "playwright";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
const repoRoot = process.cwd();
|
|
17
|
+
const port = Number(process.env.LTCAI_RELEASE_EVIDENCE_PORT || 4937);
|
|
18
|
+
const baseURL = `http://127.0.0.1:${port}`;
|
|
19
|
+
const minRatio = Number(process.env.LTCAI_HOME_FILL_MIN || "0.90");
|
|
20
|
+
|
|
21
|
+
const server = spawn(process.execPath, ["tests/visual/mock_server.cjs"], {
|
|
22
|
+
cwd: repoRoot,
|
|
23
|
+
env: { ...process.env, LTCAI_VISUAL_PORT: String(port) },
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function waitForServer() {
|
|
28
|
+
const deadline = Date.now() + 15000;
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${baseURL}/health`);
|
|
32
|
+
if (res.ok) return;
|
|
33
|
+
} catch {
|
|
34
|
+
/* retry */
|
|
35
|
+
}
|
|
36
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`visual server did not start on ${baseURL}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
await waitForServer();
|
|
43
|
+
const browser = await chromium.launch({ headless: true });
|
|
44
|
+
const context = await browser.newContext({
|
|
45
|
+
viewport: { width: 1440, height: 920 },
|
|
46
|
+
});
|
|
47
|
+
const page = await context.newPage();
|
|
48
|
+
page.setDefaultTimeout(15000);
|
|
49
|
+
|
|
50
|
+
// Same first-run reset as release capture — without this the product flow
|
|
51
|
+
// may already be complete and the wake button never appears.
|
|
52
|
+
await page.addInitScript(() => {
|
|
53
|
+
localStorage.removeItem("lattice.productFlow.complete");
|
|
54
|
+
localStorage.removeItem("lattice.productFlow.user");
|
|
55
|
+
localStorage.setItem("lattice.language", "ko");
|
|
56
|
+
localStorage.setItem("lattice.mode", "basic");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await page.goto(`${baseURL}/app`, { waitUntil: "networkidle" });
|
|
60
|
+
const korean = page.getByRole("button", { name: "한국어" });
|
|
61
|
+
if (await korean.count()) {
|
|
62
|
+
await korean.click();
|
|
63
|
+
}
|
|
64
|
+
await page.getByRole("button", { name: "Brain 지금 깨우기" }).click();
|
|
65
|
+
await page.getByText("이 Brain의 주인을 정합니다.").waitFor();
|
|
66
|
+
await page.getByPlaceholder(/You|나/).fill("Codex");
|
|
67
|
+
await page.getByPlaceholder("you@local").fill("codex@local");
|
|
68
|
+
await page.getByPlaceholder("로컬 Brain 비밀번호").fill("Lattice123");
|
|
69
|
+
await page.getByRole("button", { name: "내 Brain 시작하기" }).click();
|
|
70
|
+
await page.getByText("추천대로 시작하세요.").waitFor();
|
|
71
|
+
await page.getByRole("button", { name: "추천으로 바로 시작" }).click();
|
|
72
|
+
await page.getByText("모델을 준비하고 시작합니다.").waitFor();
|
|
73
|
+
await page.getByRole("button", { name: "준비하고 시작하기" }).click();
|
|
74
|
+
await page.waitForTimeout(180);
|
|
75
|
+
await page
|
|
76
|
+
.getByRole("button", { name: /시작|들어가|Brain 열기|Open Brain/i })
|
|
77
|
+
.first()
|
|
78
|
+
.click({ timeout: 5000 })
|
|
79
|
+
.catch(() => {});
|
|
80
|
+
await page.locator("main[aria-label='Lattice Brain']").waitFor({ timeout: 15000 });
|
|
81
|
+
await page.waitForTimeout(250);
|
|
82
|
+
|
|
83
|
+
const metrics = await page.evaluate(() => {
|
|
84
|
+
const main = document.querySelector("main[aria-label='Lattice Brain']");
|
|
85
|
+
const home =
|
|
86
|
+
document.querySelector(".brain-centered-home") ||
|
|
87
|
+
document.querySelector(".brain-home-station") ||
|
|
88
|
+
main;
|
|
89
|
+
const quiet = document.querySelector(
|
|
90
|
+
"footer.brain-home-quiet, .brain-home-quiet, .brain-home-shelves",
|
|
91
|
+
);
|
|
92
|
+
const composer = document.querySelector(
|
|
93
|
+
".brain-composer, [data-testid='brain-composer'], textarea",
|
|
94
|
+
);
|
|
95
|
+
const box = (el) => {
|
|
96
|
+
if (!el) return null;
|
|
97
|
+
const r = el.getBoundingClientRect();
|
|
98
|
+
return { top: r.top, bottom: r.bottom, height: r.height, width: r.width };
|
|
99
|
+
};
|
|
100
|
+
const candidates = [home, quiet, composer, main].filter(Boolean);
|
|
101
|
+
let bottom = 0;
|
|
102
|
+
let top = Infinity;
|
|
103
|
+
for (const el of candidates) {
|
|
104
|
+
const r = el.getBoundingClientRect();
|
|
105
|
+
bottom = Math.max(bottom, r.bottom);
|
|
106
|
+
top = Math.min(top, r.top);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
110
|
+
main: box(main),
|
|
111
|
+
home: box(home),
|
|
112
|
+
quiet: box(quiet),
|
|
113
|
+
composer: box(composer),
|
|
114
|
+
contentTop: top === Infinity ? 0 : top,
|
|
115
|
+
contentBottom: bottom,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await browser.close();
|
|
120
|
+
|
|
121
|
+
const ratio = metrics.contentBottom / metrics.viewport.height;
|
|
122
|
+
const pct = (100 * ratio).toFixed(1);
|
|
123
|
+
console.log(JSON.stringify({ ...metrics, fillRatio: ratio, fillPct: `${pct}%`, minRatio }, null, 2));
|
|
124
|
+
console.log(
|
|
125
|
+
`brain home fill: contentBottom=${metrics.contentBottom.toFixed(1)}px / viewport=${metrics.viewport.height}px = ${pct}% (need >= ${(100 * minRatio).toFixed(0)}%)`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (ratio + 1e-6 < minRatio) {
|
|
129
|
+
console.error(
|
|
130
|
+
`brain home fill FAIL: content only reaches ${pct}% of viewport (required ${(100 * minRatio).toFixed(0)}%).`,
|
|
131
|
+
);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log("brain home fill PASS");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await main();
|
|
139
|
+
} finally {
|
|
140
|
+
server.kill();
|
|
141
|
+
}
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED