Build Pigway Frame Flow with bilingual UI, complete KoKonna driver and safe automation
This commit is contained in:
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
DOCKER_BIN=${DOCKER_BIN:-docker}
|
||||
"$DOCKER_BIN" info >/dev/null
|
||||
"$DOCKER_BIN" compose config --quiet
|
||||
"$DOCKER_BIN" compose build --pull
|
||||
"$DOCKER_BIN" image inspect pigway-frame-flow:latest --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Validate Compose on a named SSH host, in a fresh temporary directory.
|
||||
|
||||
No Docker daemon access, project deployment or existing container mutation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
host = os.getenv("FRAME_FLOW_SSH_HOST", "nas-example")
|
||||
args = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host]
|
||||
remote = subprocess.check_output(
|
||||
args + ["mktemp -d /tmp/pigway-frame-flow-compose.XXXXXX"], text=True
|
||||
).strip()
|
||||
if not remote.startswith("/tmp/pigway-frame-flow-compose.") or any(
|
||||
c in remote for c in " \n\r"
|
||||
):
|
||||
raise RuntimeError("Invalid temporary path")
|
||||
subprocess.run(
|
||||
[
|
||||
"scp",
|
||||
"-O",
|
||||
str(root / "docker-compose.yml"),
|
||||
host + ":" + remote + "/docker-compose.yml",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["scp", "-O", str(root / ".env.example"), host + ":" + remote + "/.env"], check=True
|
||||
)
|
||||
subprocess.run(
|
||||
args
|
||||
+ [
|
||||
"cd " + shlex.quote(remote) + " && /usr/local/bin/docker compose config --quiet"
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
print("docker compose config: PASS (remote CLI only; not a Docker build)")
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Package only tracked project files; never include .env, data, .git or caches."""
|
||||
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "dist/pigway-frame-flow-nas.tar.gz"
|
||||
output.parent.mkdir(exist_ok=True)
|
||||
files = (
|
||||
subprocess.check_output(["git", "ls-files", "-z"], cwd=root).decode().split("\0")
|
||||
)
|
||||
with tarfile.open(output, "w:gz") as archive:
|
||||
for file in files:
|
||||
if not file or file == ".env" or file.startswith(("data/", ".git/")):
|
||||
continue
|
||||
archive.add(root / file, arcname="pigway-frame-flow/" + file)
|
||||
print(output)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Record/check persistent Mock IDs across a process/container restart."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("mode", choices=["record", "check"])
|
||||
args = parser.parse_args()
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
|
||||
token = os.getenv("FRAME_FLOW_TOKEN", "")
|
||||
if not token:
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
if line.startswith("FRAME_FLOW_TOKEN="):
|
||||
token = line.split("=", 1)[1]
|
||||
|
||||
|
||||
def api(path, method="GET", payload=None):
|
||||
request = Request(
|
||||
base + path,
|
||||
method=method,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
with urlopen(request, timeout=10) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
assert api("/api/v1/settings")["mock"] is True, "Persistence test requires Mock mode"
|
||||
record = Path("data/persistence-acceptance.json")
|
||||
if args.mode == "record":
|
||||
job = api(
|
||||
"/api/v1/jobs",
|
||||
"POST",
|
||||
{
|
||||
"name": "Persistence acceptance (paused)",
|
||||
"enabled": False,
|
||||
"schedule": {"type": "daily", "time": "07:00"},
|
||||
"source": {"type": "template", "template": "quote"},
|
||||
},
|
||||
)
|
||||
accepted = api(
|
||||
"/api/v1/render",
|
||||
"POST",
|
||||
{
|
||||
"type": "template",
|
||||
"template": "quote",
|
||||
"data": {"title": "PERSISTENCE TEST"},
|
||||
},
|
||||
)
|
||||
for _ in range(20):
|
||||
op = api("/api/v1/operations/" + accepted["operation_id"])
|
||||
if op["status"] == "COMPLETED":
|
||||
break
|
||||
time.sleep(1)
|
||||
assert op["status"] == "COMPLETED"
|
||||
record.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"job_id": job["id"],
|
||||
"operation_id": op["operation_id"],
|
||||
"asset_id": op["result"]["asset_id"],
|
||||
}
|
||||
)
|
||||
)
|
||||
print("Persistent Mock job, operation and image recorded")
|
||||
else:
|
||||
saved = json.loads(record.read_text())
|
||||
assert any(j["id"] == saved["job_id"] for j in api("/api/v1/jobs"))
|
||||
assert api("/api/v1/operations/" + saved["operation_id"])["status"] == "COMPLETED"
|
||||
assert any(a["asset_id"] == saved["asset_id"] for a in api("/api/v1/assets"))
|
||||
assert api("/ready")["status"] == "ready"
|
||||
print("Restart persistence: 4 checks passed (job/operation/asset/readiness)")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Start a loopback-only demo. Generate a private .env if absent; no cloud writes."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(root))
|
||||
env = root / ".env"
|
||||
if not env.exists():
|
||||
text = (
|
||||
(root / ".env.example")
|
||||
.read_text()
|
||||
.replace(
|
||||
"FRAME_FLOW_TOKEN=\n",
|
||||
"FRAME_FLOW_TOKEN=" + secrets.token_urlsafe(32) + "\n",
|
||||
)
|
||||
)
|
||||
env.write_text(text)
|
||||
env.chmod(0o600)
|
||||
for line in env.read_text().splitlines():
|
||||
if "=" in line and not line.startswith("#"):
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key, value)
|
||||
os.environ["DATA_DIR"] = str(root / "data")
|
||||
os.environ["CONFIG_DIR"] = str(root / "config")
|
||||
os.environ["KOKONNA_MOCK"] = "true"
|
||||
os.environ["KOKONNA_WRITE_ENABLED"] = "false"
|
||||
os.environ["BUILD_COMMIT"] = (
|
||||
subprocess.run(
|
||||
["git", "rev-parse", "HEAD"], cwd=root, capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
or "development"
|
||||
)
|
||||
|
||||
os.environ["BUILD_TIME"] = datetime.now(UTC).isoformat()
|
||||
uvicorn.run(
|
||||
"pigway_frame_flow.main:app", host="127.0.0.1", port=18787, access_log=False
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Read-only HTTP smoke checks. No frame writes; token read from env/private .env."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
base = os.getenv("FRAME_FLOW_URL", "http://127.0.0.1:18787").rstrip("/")
|
||||
token = os.getenv("FRAME_FLOW_TOKEN", "")
|
||||
if not token and Path(".env").exists():
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
if line.startswith("FRAME_FLOW_TOKEN="):
|
||||
token = line.split("=", 1)[1]
|
||||
for path in [
|
||||
"/health",
|
||||
"/ready",
|
||||
"/api/v1/version",
|
||||
"/openapi.json",
|
||||
"/docs",
|
||||
"/",
|
||||
"/api/v1/devices",
|
||||
"/api/v1/templates",
|
||||
"/api/v1/jobs",
|
||||
]:
|
||||
request = Request(base + path, headers={"Authorization": "Bearer " + token})
|
||||
with urlopen(request, timeout=15) as response:
|
||||
body = response.read()
|
||||
print(path, "HTTP", response.status, "bytes", len(body))
|
||||
assert response.status == 200
|
||||
print("Read-only smoke checks passed")
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Headless test of this app only; never attaches to the user's browser session. */
|
||||
const fs=require('fs');const path=require('path');const assert=require('assert');const {chromium}=require('playwright');
|
||||
(async()=>{
|
||||
const root=path.resolve(__dirname,'..');const env=fs.readFileSync(path.join(root,'.env'),'utf8');const token=env.split('\n').find(x=>x.startsWith('FRAME_FLOW_TOKEN=')).slice('FRAME_FLOW_TOKEN='.length);
|
||||
const base='http://127.0.0.1:18787';const response=await fetch(base+'/api/v1/settings',{headers:{Authorization:'Bearer '+token}});const settings=await response.json();assert(settings.mock===true,'UI smoke requires Mock mode');
|
||||
const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message.replaceAll(token,'[REDACTED]')));let checks=0;
|
||||
await page.goto(base);await page.locator('#api-token').fill(token);await page.locator('#login-form button').click();await page.locator('.hero').waitFor();assert(await page.locator('h2').first().innerText()==='客厅相框');checks++;
|
||||
fs.mkdirSync(path.join(root,'docs/screenshots'),{recursive:true});await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-light-zh.png'),fullPage:true});
|
||||
await page.locator('#lang').click();await page.getByRole('heading',{name:'Dashboard',exact:true}).waitFor();checks++;
|
||||
await page.locator('#theme').click();assert(await page.locator('html').getAttribute('data-theme')==='dark');checks++;
|
||||
await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-dark-en.png'),fullPage:true});
|
||||
await page.locator('[data-page="gallery"]').click();await page.locator('.image-card').first().waitFor();await page.locator('.image-card .thumb').first().evaluate(img=>new Promise((resolve,reject)=>{if(img.complete&&img.naturalWidth)return resolve();img.onload=resolve;img.onerror=reject;}));checks++;
|
||||
await page.locator('#show-name').click();await page.locator('#image-name').fill('Original Art.jpg');await page.locator('#modal-cancel').click();checks++;
|
||||
await page.locator('[data-page="templates"]').click();await page.locator('.template-card').first().waitFor();assert(await page.locator('.template-card').count()===6);checks++;
|
||||
await page.locator('#render-template').click();await page.locator('#template-image').waitFor({timeout:30000});assert(await page.locator('#display-template').isEnabled());checks++;
|
||||
await page.locator('#display-template').click();await page.locator('[data-page="history"]').click();await page.locator('tbody tr').first().waitFor();checks++;
|
||||
await page.locator('[data-page="automation"]').click();await page.locator('#new-job').click();await page.locator('#job-name').fill('UI acceptance');await page.locator('#job-form button[type=submit]').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor();checks++;
|
||||
await page.locator('[data-job-toggle]').first().click();await page.getByText('Paused',{exact:true}).first().waitFor();checks++;
|
||||
await page.locator('[data-job-delete]').first().click();await page.locator('#modal-confirm').click();await page.locator('td strong').filter({hasText:'UI acceptance'}).waitFor({state:'hidden'});checks++;
|
||||
await page.locator('[data-page="playlists"]').click();await page.locator('#new-playlist').click();await page.locator('#playlist-name').fill('UI art');await page.locator('#playlist-images').fill('1');await page.locator('#playlist-form button[type=submit]').click();await page.getByText('UI art',{exact:true}).waitFor();checks++;
|
||||
await page.locator('[data-pl-delete]').click();await page.locator('#modal-confirm').click();checks++;
|
||||
await page.locator('[data-page="settings"]').click();await page.locator('#theme-select').waitFor();await page.locator('#theme-select').selectOption('light');assert(await page.locator('html').getAttribute('data-theme')==='light');checks++;
|
||||
await page.locator('#language-select').selectOption('zh');await page.getByRole('heading',{name:'设置',exact:true}).waitFor();checks++;
|
||||
await page.setViewportSize({width:390,height:844});await page.locator('[data-page="dashboard"]').click();await page.locator('.hero').waitFor();assert(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth));checks++;
|
||||
await page.screenshot({path:path.join(root,'docs/screenshots/dashboard-mobile-zh.png'),fullPage:true});
|
||||
assert(errors.length===0,JSON.stringify(errors));checks++;
|
||||
await browser.close();console.log('Browser smoke:',checks,'passed; page errors:',errors.length);
|
||||
})().catch(error=>{console.error(error.message);process.exit(1)});
|
||||
Reference in New Issue
Block a user