Release v0.1.9: enforce Photos API boundary and finalize Server migration
This commit is contained in:
@@ -70,6 +70,13 @@ class Deploy:
|
||||
return json.loads(self.run("inspect", name).stdout)[0]
|
||||
|
||||
def settings(self):
|
||||
if any(
|
||||
self.env.get(key, "").startswith("/volume2/docker/pigway-frame-flow")
|
||||
for key in ("PROD_DATA_DIR", "PROD_CONFIG_DIR")
|
||||
):
|
||||
raise ValueError(
|
||||
"NAS Frame deployment is retired; target the Ubuntu Server"
|
||||
)
|
||||
missing = [key for key in REQUIRED if not self.env.get(key)]
|
||||
if missing:
|
||||
raise ValueError("Missing Variables: " + ", ".join(missing))
|
||||
@@ -118,20 +125,9 @@ class Deploy:
|
||||
or not m.get("RW")
|
||||
):
|
||||
raise ValueError("Production mount mismatch: " + key)
|
||||
photos = self.env.get("PROD_PHOTOS_DIR")
|
||||
if "/photos" in mounts and (
|
||||
not photos
|
||||
or mounts["/photos"]["Source"] != photos
|
||||
or mounts["/photos"]["RW"]
|
||||
):
|
||||
raise ValueError(
|
||||
"Confirm PROD_PHOTOS_DIR for the existing read-only photo mount"
|
||||
)
|
||||
if photos and "/photos" not in mounts:
|
||||
raise ValueError(
|
||||
"Configured photos directory does not match current container"
|
||||
)
|
||||
if set(mounts) != {"/data", "/config"} | ({"/photos"} if photos else set()):
|
||||
if self.env.get("PROD_PHOTOS_DIR") or "/photos" in mounts:
|
||||
raise ValueError("Filesystem photo mounts are retired; use the Photos API")
|
||||
if set(mounts) != {"/data", "/config"}:
|
||||
raise ValueError("Unrecognized persistent mount; review before adopting CD")
|
||||
bindings = old["HostConfig"].get("PortBindings", {}).get("18787/tcp")
|
||||
if bindings != [
|
||||
@@ -180,16 +176,6 @@ class Deploy:
|
||||
)
|
||||
]
|
||||
service["group_add"] = old["HostConfig"].get("GroupAdd") or []
|
||||
if photos:
|
||||
service["volumes"].append(
|
||||
{
|
||||
"type": "bind",
|
||||
"source": photos,
|
||||
"target": "/photos",
|
||||
"read_only": True,
|
||||
"bind": {"create_host_path": False},
|
||||
}
|
||||
)
|
||||
# Adoption must preserve runtime constraints, not silently change them.
|
||||
if (
|
||||
old["Config"]["User"] != service["user"]
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Release a verified source bundle on the Ubuntu T7 host; never write to frames."""
|
||||
|
||||
import datetime
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path("/srv/pigway/apps/pigway-frame-flow")
|
||||
RECOVERY = Path("/srv/pigway/recovery/frame-flow")
|
||||
CONTAINER = "pigway-frame-flow"
|
||||
|
||||
|
||||
def run(*args):
|
||||
return subprocess.check_output(args, text=True, timeout=600).strip()
|
||||
|
||||
|
||||
def inspect():
|
||||
return json.loads(run("docker", "inspect", CONTAINER))[0]
|
||||
|
||||
|
||||
def archive_staging(backup, ctx):
|
||||
# These are retired Frame-only inputs, never shared console migration files.
|
||||
names = [
|
||||
"frame-schedule-ui",
|
||||
"frame-photos-ui",
|
||||
"frame-final-status.sh",
|
||||
"frame-compose.candidate.private.json",
|
||||
"frame-fresh-compose.private.json",
|
||||
"frame-image.tar",
|
||||
"frame-prepare.sh",
|
||||
"frame-nfs-setup.sh",
|
||||
"frame.unit",
|
||||
"compose.ubuntu-photos-api.yml",
|
||||
]
|
||||
archive = backup / "retired-frame-staging"
|
||||
archive.mkdir(mode=0o700)
|
||||
moved = []
|
||||
for name in names:
|
||||
source = ctx.parent / name
|
||||
if source.exists():
|
||||
assert not source.is_symlink(), name
|
||||
source.rename(archive / name)
|
||||
moved.append(name)
|
||||
for p in archive.rglob("*"):
|
||||
if not p.is_symlink():
|
||||
p.chmod(0o700 if p.is_dir() else 0o600)
|
||||
# Remove only disposable aliases, preserving every recovery reference and image.
|
||||
references = "\n".join(
|
||||
p.read_text(errors="replace")
|
||||
for p in RECOVERY.rglob("*")
|
||||
if p.is_file() and p.suffix in {".json", ".txt"}
|
||||
)
|
||||
removed = []
|
||||
for tag in [
|
||||
"pigway-frame-flow:history-ui-local-base",
|
||||
"pigway-frame-flow:final-audit-local-base",
|
||||
]:
|
||||
tags = run(
|
||||
"docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}"
|
||||
).splitlines()
|
||||
if tag in tags and tag not in references:
|
||||
run("docker", "image", "rm", tag)
|
||||
removed.append(tag)
|
||||
return {
|
||||
"archived_frame_inputs": moved,
|
||||
"removed_temporary_aliases": removed,
|
||||
"shared_inputs_and_recovery_preserved": True,
|
||||
}
|
||||
|
||||
|
||||
def verify_runtime(commit):
|
||||
c = inspect()
|
||||
env = dict(s.split("=", 1) for s in c["Config"]["Env"])
|
||||
assert env["KOKONNA_MOCK"] == "false"
|
||||
assert env["KOKONNA_WRITE_ENABLED"] == "true"
|
||||
assert env["BUILD_COMMIT"] == commit
|
||||
assert c["State"]["Running"]
|
||||
assert c["Config"]["User"] == "10333:10333"
|
||||
assert c["HostConfig"]["ReadonlyRootfs"]
|
||||
assert set(c["HostConfig"]["CapDrop"]) == {"ALL"}
|
||||
assert {m["Destination"] for m in c["Mounts"]} == {"/data", "/config"}
|
||||
for mount in c["Mounts"]:
|
||||
assert Path(mount["Source"]).resolve() == APP / mount["Destination"][1:]
|
||||
return c
|
||||
|
||||
|
||||
def main():
|
||||
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server"
|
||||
os.umask(0o077)
|
||||
run("/usr/local/libexec/pigway-guard-mount")
|
||||
assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == (
|
||||
"00000000-0000-4000-8000-000000000001"
|
||||
)
|
||||
with (RECOVERY / ".release.lock").open("a") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
release(lock)
|
||||
|
||||
|
||||
def release(lock):
|
||||
ctx = Path(__file__).resolve().parent.parent
|
||||
assert ctx.parent == Path("/srv/pigway/migration-inbox")
|
||||
commit = (ctx / "RELEASE_COMMIT").read_text().strip()
|
||||
assert re.fullmatch("[0-9a-f]{40}", commit)
|
||||
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text())
|
||||
for name, expected in hashes.items():
|
||||
p = (ctx / name).resolve()
|
||||
assert p.is_relative_to(ctx)
|
||||
assert hashlib.sha256(p.read_bytes()).hexdigest() == expected, name
|
||||
before = inspect()
|
||||
old_env = dict(s.split("=", 1) for s in before["Config"]["Env"])
|
||||
verify_runtime(old_env["BUILD_COMMIT"])
|
||||
assert run("docker", "exec", CONTAINER, "cat", "/app/requirements.lock") == (
|
||||
(ctx / "requirements.lock").read_text().strip()
|
||||
), "Dependencies differ; full build required"
|
||||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
backup = RECOVERY / ("release-" + stamp + "-" + commit[:12])
|
||||
backup.mkdir(mode=0o700)
|
||||
shutil.copy2(APP / "compose.json", backup / "compose.before.json")
|
||||
shutil.copytree(APP / "config", backup / "config")
|
||||
with sqlite3.connect(
|
||||
"file:" + str(APP / "data/frameflow.db") + "?mode=ro", uri=True
|
||||
) as src:
|
||||
with sqlite3.connect(backup / "frameflow.before.db") as dst:
|
||||
src.backup(dst)
|
||||
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
|
||||
base = "pigway-frame-flow:release-base-" + commit[:12]
|
||||
run("docker", "image", "tag", before["Image"], base)
|
||||
image = "pigway-frame-flow:0.1.9-" + commit[:12]
|
||||
try:
|
||||
run(
|
||||
"docker",
|
||||
"build",
|
||||
"--pull=false",
|
||||
"--network=none",
|
||||
"-f",
|
||||
str(ctx / "deploy/Dockerfile.runtime-update"),
|
||||
"--build-arg",
|
||||
"BASE_IMAGE=" + base,
|
||||
"--build-arg",
|
||||
"BUILD_COMMIT=" + commit,
|
||||
"--build-arg",
|
||||
"BUILD_TIME=" + stamp,
|
||||
"-t",
|
||||
image,
|
||||
str(ctx),
|
||||
)
|
||||
finally:
|
||||
run("docker", "image", "rm", base)
|
||||
compose = json.loads((APP / "compose.json").read_text())
|
||||
service = compose["services"][CONTAINER]
|
||||
service["image"] = image
|
||||
service["environment"]["BUILD_COMMIT"] = commit
|
||||
service["environment"]["BUILD_TIME"] = stamp
|
||||
changed = False
|
||||
try:
|
||||
changed = True
|
||||
(APP / "compose.json").write_text(json.dumps(compose, indent=2) + "\n")
|
||||
run("systemctl", "restart", "pigway-frame-flow.service")
|
||||
for attempt in range(30):
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
"http://127.0.0.1:81/ready", timeout=4
|
||||
) as r:
|
||||
assert json.load(r)["status"] == "ready"
|
||||
break
|
||||
except Exception:
|
||||
if attempt == 29:
|
||||
raise
|
||||
time.sleep(2)
|
||||
current = verify_runtime(commit)
|
||||
assert current["Image"] == run(
|
||||
"docker", "image", "inspect", "--format", "{{.Id}}", image
|
||||
)
|
||||
# Verify all deployed project files, not just a version string or an image tag.
|
||||
project_hashes = {
|
||||
n: h
|
||||
for n, h in hashes.items()
|
||||
if n.startswith(("pigway_frame_flow/", "vendor/"))
|
||||
or n == "requirements.lock"
|
||||
}
|
||||
code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())'
|
||||
subprocess.run(
|
||||
["docker", "exec", "-i", CONTAINER, "python", "-c", code],
|
||||
input=json.dumps(project_hashes),
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
for name in [
|
||||
"app.js",
|
||||
"features.js",
|
||||
"extensions.js",
|
||||
"i18n.js",
|
||||
"index.html",
|
||||
"style.css",
|
||||
]:
|
||||
url = "http://127.0.0.1:81/" + (
|
||||
"" if name == "index.html" else "ui/" + name
|
||||
)
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
assert (
|
||||
response.read()
|
||||
== (ctx / "pigway_frame_flow/web" / name).read_bytes()
|
||||
)
|
||||
run("systemctl", "is-active", "pigway-frame-flow.service")
|
||||
except BaseException:
|
||||
if changed:
|
||||
shutil.copy2(backup / "compose.before.json", APP / "compose.json")
|
||||
run("systemctl", "restart", "pigway-frame-flow.service")
|
||||
raise
|
||||
shutil.copy2(APP / "compose.json", RECOVERY / "compose.json")
|
||||
(RECOVERY / "image.txt").write_text(image + "\n")
|
||||
shutil.copy2(ctx / "source.tar.gz", backup / "source.tar.gz")
|
||||
cleanup = archive_staging(backup, ctx)
|
||||
receipt = {
|
||||
"result": "PASS",
|
||||
"commit": commit,
|
||||
"image": image,
|
||||
"image_id": current["Image"],
|
||||
"backup": str(backup),
|
||||
"mock": False,
|
||||
"write_enabled": True,
|
||||
"checked_at": stamp,
|
||||
"cleanup": cleanup,
|
||||
}
|
||||
output = APP / "release-verification.json"
|
||||
output.write_text(json.dumps(receipt, indent=2) + "\n")
|
||||
owner = ctx.stat()
|
||||
os.chown(output, owner.st_uid, owner.st_gid)
|
||||
shutil.copy2(output, backup / "release-verification.json")
|
||||
shutil.rmtree(ctx)
|
||||
print(json.dumps(receipt, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
|
||||
const root=path.join(__dirname,'../pigway_frame_flow/web');
|
||||
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
|
||||
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
|
||||
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
|
||||
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
|
||||
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
|
||||
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
|
||||
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
|
||||
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
|
||||
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
|
||||
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
|
||||
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
|
||||
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
|
||||
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}});
|
||||
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
|
||||
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,28 @@
|
||||
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
|
||||
const root=path.join(__dirname,'../pigway_frame_flow/web');
|
||||
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
|
||||
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
|
||||
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
|
||||
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
|
||||
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
|
||||
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
|
||||
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
|
||||
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
|
||||
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
|
||||
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
|
||||
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
|
||||
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
|
||||
await p.evaluate(async()=>{state.templates=[{id:'daily-dashboard',name:'Dashboard',defaults:{}}];await jobEditor();});
|
||||
for(const unit of ['hours','minutes']){
|
||||
await p.locator('#job-schedule').selectOption(unit);await p.locator('#schedule-value').fill('2');
|
||||
assert.equal(await p.locator('#schedule-value').getAttribute('type'),'number');
|
||||
assert(await p.locator('#schedule-hint').innerText());
|
||||
}
|
||||
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
|
||||
await p.locator('#job-name').fill('Test interval');
|
||||
await p.locator('#job-form button[type=submit]').click();await p.waitForTimeout(100);
|
||||
assert(saved);assert.deepEqual(saved.schedule,{type:'interval',interval_minutes:2});
|
||||
await p.evaluate(async()=>{await jobEditor({id:'existing',name:'Existing',schedule:{type:'interval',interval_minutes:120},source:{type:'template',template:'daily-dashboard',data:{}},enabled:false,display_duration:0,restore_previous:false,priority:50});});
|
||||
assert.equal(await p.locator('#job-schedule').inputValue(),'hours');assert.equal(await p.locator('#schedule-value').inputValue(),'2');
|
||||
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的相框'}];});assert.equal(await p.evaluate(()=>deviceDisplayName(state.devices[0])),'我的相框');assert.equal(await p.evaluate(()=>deviceDisplayName({id:'living-room',name:'living-room'})),lang==='zh'?'客厅':'Living room');await p.locator('#modal-cancel').click();await p.evaluate(async()=>{const previous=api;api=async()=>[{id:'fixture',name:'Family',device_id:'living-room',schedule:{type:'interval',interval_minutes:120},enabled:false}];try{await views.automation(state.epoch);}finally{api=previous;}});assert.equal(await p.locator('tbody td small').first().innerText(),'我的相框');assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
|
||||
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,17 @@
|
||||
const {chromium}=require('playwright');const fs=require('fs');const path=require('path');const assert=require('assert');
|
||||
const root=path.resolve(__dirname,'../pigway_frame_flow/web');const out=process.env.FRAME_FLOW_SCREENSHOT_DIR;if(out)fs.mkdirSync(out,{recursive:true});
|
||||
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});let count=0;
|
||||
try{for(const width of [1440,768,390,320])for(const lang of ['zh','en'])for(const theme of ['dark','light']){
|
||||
const p=await b.newPage({viewport:{width,height:900}});const errors=[];p.on('pageerror',e=>errors.push(e.message));
|
||||
await p.addInitScript(({lang,theme})=>{localStorage.setItem('ff-language',lang);localStorage.setItem('ff-theme',theme)},{lang,theme});
|
||||
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){if(u.pathname==='/api/v1/version')return r.fulfill({json:{version:'0.1.9',commit:'ba9424a-ui',build_time:'validation'}});return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}let f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');const file=path.join(root,f);if(!file.startsWith(root)||!fs.existsSync(file))return r.fulfill({status:404,body:''});return r.fulfill({body:fs.readFileSync(file),contentType:f.endsWith('.css')?'text/css':f.endsWith('.js')?'application/javascript':'text/html'});});
|
||||
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.waitForTimeout(60);
|
||||
assert.equal(await p.locator('#device option:checked').innerText(),lang==='zh'?'客厅':'Living room');
|
||||
const layout=await p.evaluate(()=>{const q=s=>document.querySelector(s).getBoundingClientRect();return {overflow:document.documentElement.scrollWidth>innerWidth,card:q('.login-card').toJSON(),form:q('#login-form').toJSON(),user:q('#username').toJSON(),password:q('#password').toJSON(),remember:document.querySelector('#remember-login').closest('label').getBoundingClientRect().toJSON(),button:q('#login-form button').toJSON(),label:document.querySelector('#remember-login').labels[0].innerText}});
|
||||
assert(!layout.overflow,JSON.stringify({width,lang,theme,layout}));assert(layout.password.top-layout.user.bottom>=24);assert(layout.button.top-layout.remember.bottom>=20);assert(layout.button.width>=layout.form.width-1);assert.equal(errors.length,0,errors.join('\n'));
|
||||
// Language switching and custom names must not change the internal device ID.
|
||||
await p.evaluate(()=>{state.devices=[{id:'living-room',name:'我的画框'}];shell()});assert.equal(await p.locator('#device option:checked').innerText(),'我的画框');
|
||||
await p.evaluate(()=>{state.devices=[];shell();login()});
|
||||
if(out&&lang==='zh'&&theme==='dark'&&[1440,390].includes(width))await p.screenshot({path:path.join(out,`login-${width}.png`),fullPage:true});
|
||||
console.log('PASS',width,lang,theme);count++;await p.close();}
|
||||
console.log('TOTAL',count,'profiles');}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
@@ -0,0 +1,23 @@
|
||||
const {chromium}=require('playwright');const fs=require('fs'),path=require('path'),assert=require('assert');
|
||||
const root=path.join(__dirname,'../pigway_frame_flow/web');
|
||||
(async()=>{const b=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});try{for(const width of [1440,768,390])for(const lang of ['zh','en']){
|
||||
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
|
||||
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
|
||||
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
|
||||
if(r.request().method()==='PUT'){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
|
||||
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
|
||||
if(u.pathname==='/api/v1/integrations/synology-photos')return r.fulfill({json:{configured:true,connected:true}});
|
||||
if(u.pathname.endsWith('/people'))return r.fulfill({json:[{id:1,name:'Test Person',count:5}]});
|
||||
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
|
||||
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
|
||||
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();await p.evaluate(()=>photoSourceForm());
|
||||
assert(!(await p.locator('#source-kind').isVisible()));assert(!(await p.locator('#source-id').isVisible()));assert(!(await p.locator('#source-synology-previews').isVisible()));
|
||||
assert.equal(await p.locator('#source-kind').inputValue(),'synology-photos');assert.match(await p.locator('#source-id').inputValue(),/^photos-[a-z0-9-]+$/);
|
||||
const text=await p.locator('#modal').innerText();assert(!/\/photos|本地目录|local.folder|mounted/i.test(text));
|
||||
assert(await p.locator('#source-people-space').isVisible());await p.locator('#source-people-load').click();await p.locator('[data-person-id], #source-people-panel input[type=checkbox]').last().waitFor();
|
||||
assert(!await p.evaluate(()=>document.documentElement.scrollWidth>innerWidth));
|
||||
await p.locator('#source-name').fill('Family');
|
||||
await p.locator('#photo-source-form button[type=submit]').click();await p.waitForTimeout(100);
|
||||
assert(saved);assert.equal(saved.kind,'synology-photos');assert.equal(saved.synology_previews,false);
|
||||
await p.evaluate(()=>photoSourceForm({id:'family-photos',name:'Family',kind:'synology-photos',location:'.',people_filter:true,person_ids:[1],people_space:'personal',person_match:'all'}));assert.equal(await p.locator('#source-id').inputValue(),'family-photos');assert.equal(await p.locator('#source-people-space').inputValue(),'personal');assert.equal(await p.locator('#source-person-match').inputValue(),'all');assert(await p.locator('#source-people-filter').isChecked());if(lang==='zh')await p.screenshot({path:'/tmp/frame-source-'+width+'.png',fullPage:true});assert.equal(errors.length,0,errors.join('\n'));console.log('PASS',width,lang);await p.close();
|
||||
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||
Reference in New Issue
Block a user