SHA256
feat(android): align native preview with measured PWA design
This commit is contained in:
1 parent
bd0e4cc4fb
commit
7ac9882307
30 files changed
+2793
-750
No files matched your search
@@ -0,0 +1,105 @@
|
||||
// Browser automation is a reference-measurement tool, never an application renderer.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const [playwrightModule, destination] = process.argv.slice(2);
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require(playwrightModule);
|
||||
const origin = process.env.YUVOMI_TEST_URL;
|
||||
if (!/^https:\/\/127\.0\.0\.1:\d+$/.test(origin)) throw new Error('Only the temporary loopback fixture is allowed');
|
||||
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
|
||||
const width = Number(process.env.YUVOMI_VISUAL_WIDTH || 360);
|
||||
const height = Number(process.env.YUVOMI_VISUAL_HEIGHT || 640);
|
||||
const measurements = {
|
||||
upstreamRevision: '10bfef7686ed7303b4c74c7fcc55a5eb16356c9d', browser: browser.version(),
|
||||
androidFontConfiguration: Boolean(process.env.FONTCONFIG_FILE),
|
||||
fixture: { widgets: ['tasks', 'calendar', 'shopping', 'notes'], mobileFavorites: ['tasks', 'kitchen', 'notes'] }, screens: [],
|
||||
};
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, reducedMotion: 'reduce',
|
||||
locale: 'de-DE', timezoneId: 'Europe/Berlin', ignoreHTTPSErrors: true,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort());
|
||||
const api = context.request;
|
||||
const password = process.env.YUVOMI_TEST_PASSWORD;
|
||||
let csrf = '';
|
||||
async function request(route, data) {
|
||||
const response = await api.fetch(origin + '/api/v1' + route, {
|
||||
method: data === undefined ? 'GET' : (route === '/preferences' ? 'PUT' : 'POST'),
|
||||
data, headers: { 'X-CSRF-Token': csrf },
|
||||
});
|
||||
if (!response.ok()) throw new Error(`Fixture API failed at ${route}: HTTP ${response.status()}`);
|
||||
const result = await response.json();
|
||||
if (result.csrfToken) csrf = result.csrfToken;
|
||||
return result;
|
||||
}
|
||||
await request('/auth/setup', { username: 'mobiletest', display_name: 'Mobile Test', password });
|
||||
await request('/auth/login', { username: 'mobiletest', password });
|
||||
await request('/auth/onboarding-seen', {});
|
||||
await request('/auth/changelog-seen', {});
|
||||
const today = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
|
||||
await request('/tasks', { title: 'Gemeinsam den Alltag planen', priority: 'high', due_date: today });
|
||||
await request('/notes', { title: 'Willkommen zuhause', content: 'Native Oberfläche, eigener Server.', pinned: 1 });
|
||||
const list = await request('/shopping', { name: 'Wochenmarkt' });
|
||||
await request(`/shopping/${list.data.id}/items`, { name: 'Äpfel', quantity: '4' });
|
||||
// Match the implemented native slice, not a fabricated full-module acceptance.
|
||||
await request('/preferences', {
|
||||
dashboard_widgets: ['tasks', 'calendar', 'shopping', 'notes'].map((id, order) => ({ id, visible: true, order, size: '1x1' })),
|
||||
dashboard_today_glance: false, mobile_nav_order: ['tasks', 'kitchen', 'notes'],
|
||||
timezone: 'Europe/Berlin', timezone_hint_dismissed: true,
|
||||
});
|
||||
await request('/auth/logout', {});
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('yuvomi-language', 'de');
|
||||
localStorage.setItem('yuvomi-lang', 'de');
|
||||
});
|
||||
const selectors = ['.auth-page', '.auth-hero', '.auth-hero__mark', '.auth-hero__title', '.auth-hero__tagline', '.label', '.input', '.btn--primary', '.dashboard-overview__date',
|
||||
'.dashboard-overview__title', '.widget', '.widget__header', '.widget__title', '.widget__body', '.nav-bottom__items',
|
||||
'.nav-bottom .nav-item', '.page-toolbar', '.page-toolbar__title', '.task-card', '.task-card__title', '.note-card', '.list-row'];
|
||||
async function capture(name) {
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// Compare neutral states, not a keyboard focus ring left by fixture navigation.
|
||||
await page.evaluate(() => document.activeElement?.blur?.());
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await page.screenshot({ path: path.join(destination, `${name}.png`), animations: 'disabled' });
|
||||
measurements.screens.push({ name, viewport: page.viewportSize(), elements: await page.evaluate(selectors =>
|
||||
selectors.flatMap(selector => [...document.querySelectorAll(selector)].slice(0, 5).map(element => {
|
||||
const bounds = element.getBoundingClientRect(); const style = getComputedStyle(element);
|
||||
return { selector, text: element.matches('input') ? '' : element.textContent.trim().slice(0, 80),
|
||||
x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height,
|
||||
font: style.fontFamily, fontSize: style.fontSize, fontWeight: style.fontWeight, lineHeight: style.lineHeight,
|
||||
letterSpacing: style.letterSpacing, color: style.color, background: style.backgroundColor,
|
||||
radius: style.borderRadius, padding: style.padding };
|
||||
})), selectors) });
|
||||
}
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' });
|
||||
await page.goto(origin + '/login');
|
||||
await page.evaluate(value => localStorage.setItem('yuvomi-theme', value), theme);
|
||||
await page.reload();
|
||||
await page.locator('#auth-btn').waitFor();
|
||||
await capture(`login-${theme}-${width}`);
|
||||
await page.locator('#username').fill('mobiletest');
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('#auth-btn').click();
|
||||
await page.locator('.dashboard-overview__title').waitFor();
|
||||
await page.locator('.widget--tasks .widget__body').waitFor();
|
||||
await capture(`dashboard-${theme}-${width}`);
|
||||
for (const [route, selector, name] of [['/tasks', '.task-card', 'tasks'], ['/notes', '.note-card', 'notes'], ['/shopping', '.shopping-item', 'shopping']]) {
|
||||
await page.goto(origin + route);
|
||||
await page.locator(selector).first().waitFor();
|
||||
await capture(`${name}-${theme}-${width}`);
|
||||
}
|
||||
await request('/auth/me');
|
||||
await request('/auth/logout', {});
|
||||
}
|
||||
await context.close();
|
||||
} finally {
|
||||
fs.writeFileSync(path.join(destination, 'measurements.json'), JSON.stringify(measurements, null, 2) + '\n');
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the unmodified pinned PWA with synthetic data, never a user's server."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
||||
|
||||
|
||||
def run(upstream, playwright_module, destination, width=360, height=640):
|
||||
revision = subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip()
|
||||
assert revision == REVISION, "Visual reference must use pinned unmodified upstream"
|
||||
assert not subprocess.check_output(["git", "-C", str(upstream), "diff", "HEAD", "--name-only"], text=True).strip()
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
base = "/tmp/opencode" if Path("/tmp/opencode").exists() else None
|
||||
with tempfile.TemporaryDirectory(prefix="yuvomi-visual-", dir=base) as temporary:
|
||||
directory = Path(temporary)
|
||||
subprocess.run([
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
|
||||
"-keyout", str(directory / "key.pem"), "-out", str(directory / "cert.pem"),
|
||||
"-subj", "/CN=Yuvomi visual fixture",
|
||||
"-addext", "subjectAltName=IP:127.0.0.1,DNS:localhost",
|
||||
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
env = {**os.environ, "NODE_EXTRA_CA_CERTS": str(directory / "cert.pem"),
|
||||
"YUVOMI_TEST_DISABLE_OIDC": "true", "YUVOMI_TEST_PASSWORD": secrets.token_urlsafe(24),
|
||||
"YUVOMI_VISUAL_WIDTH": str(width), "YUVOMI_VISUAL_HEIGHT": str(height)}
|
||||
with (directory / "server.log").open("w") as log:
|
||||
server = subprocess.Popen(["node", str(ROOT / "scripts/integration-server.mjs"), str(upstream), temporary],
|
||||
cwd=upstream, env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(200):
|
||||
if (directory / "ready.json").exists():
|
||||
break
|
||||
if server.poll() is not None:
|
||||
raise RuntimeError("Visual fixture failed to start; no private logs exported")
|
||||
time.sleep(0.1)
|
||||
ready = json.loads((directory / "ready.json").read_text())
|
||||
env["YUVOMI_TEST_URL"] = f"https://127.0.0.1:{ready['port']}"
|
||||
subprocess.run(["node", str(ROOT / "scripts/capture-pwa-reference.mjs"),
|
||||
str(playwright_module), str(destination)], env=env, check=True)
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("upstream", type=Path)
|
||||
parser.add_argument("--playwright-module", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/visual/pwa")
|
||||
parser.add_argument("--width", type=int, default=360)
|
||||
parser.add_argument("--height", type=int, default=640)
|
||||
args = parser.parse_args()
|
||||
assert 320 <= args.width <= 1600 and 320 <= args.height <= 1800, "Invalid reference viewport"
|
||||
run(args.upstream.resolve(), args.playwright_module.resolve(), args.output.resolve(), args.width, args.height)
|
||||
@@ -0,0 +1,53 @@
|
||||
// Development-only side-by-side evidence. This deliberately asserts no pixel parity.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const [modulePath, nativeDirectory, pwaDirectory, destination] = process.argv.slice(2);
|
||||
if (!destination) throw new Error('Usage: node compare-native-pwa.mjs PLAYWRIGHT_MODULE NATIVE_DIR PWA_DIR OUTPUT_DIR');
|
||||
const { chromium } = createRequire(import.meta.url)(path.resolve(modulePath));
|
||||
const viewport = JSON.parse(fs.readFileSync(path.join(nativeDirectory, 'viewport.json')));
|
||||
if (viewport.fontScale !== 1) throw new Error('This CSS reference comparison requires fontScale 1');
|
||||
const cssWidth = viewport.widthPx / viewport.density;
|
||||
const cssHeight = viewport.heightPx / viewport.density;
|
||||
const reference = JSON.parse(fs.readFileSync(path.join(pwaDirectory, 'measurements.json')));
|
||||
if (!reference.screens.every(screen => screen.viewport.width === cssWidth && screen.viewport.height === cssHeight)) {
|
||||
throw new Error('PWA and native content viewports do not match');
|
||||
}
|
||||
function image(directory, name) {
|
||||
const bytes = fs.readFileSync(path.join(directory, name));
|
||||
if (bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
|
||||
bytes.readUInt32BE(16) !== viewport.widthPx || bytes.readUInt32BE(20) !== viewport.heightPx) {
|
||||
throw new Error(`Invalid or mismatched PNG: ${name}`);
|
||||
}
|
||||
return { uri: `data:image/png;base64,${bytes.toString('base64')}`, sha256: createHash('sha256').update(bytes).digest('hex') };
|
||||
}
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, args: process.getuid?.() === 0 ? ['--no-sandbox'] : [] });
|
||||
const records = [];
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: viewport.widthPx * 2, height: viewport.heightPx + 48 }, deviceScaleFactor: 1 });
|
||||
await page.route('**/*', route => route.abort());
|
||||
for (const name of ['login', 'dashboard', 'tasks', 'notes', 'shopping']) {
|
||||
for (const theme of ['light', 'dark']) {
|
||||
const native = image(nativeDirectory, `${name}-${theme}.png`);
|
||||
const pwa = image(pwaDirectory, `${name}-${theme}-${cssWidth}.png`);
|
||||
await page.setContent(`<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
|
||||
<style>body{margin:0;background:#eee;font:20px sans-serif}main{display:flex}section{width:${viewport.widthPx}px}
|
||||
header{height:48px;display:grid;place-items:center}img{display:block;width:100%}</style>
|
||||
<main><section><header>PWA 2.78.0 · ${name} · ${theme}</header><img src="${pwa.uri}"></section>
|
||||
<section><header>Android nativ · ${name} · ${theme} · keine 1:1-Abnahme</header><img src="${native.uri}"></section></main>`);
|
||||
await page.locator('img').evaluateAll(images => Promise.all(images.map(image => image.decode())));
|
||||
const file = `${name}-${theme}-comparison.png`;
|
||||
await page.screenshot({ path: path.join(destination, file) });
|
||||
records.push({ file, nativeSha256: native.sha256, pwaSha256: pwa.sha256 });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
fs.writeFileSync(path.join(destination, 'comparison.json'), JSON.stringify({
|
||||
status: 'Side-by-side evidence only; no visual acceptance', viewport, upstreamRevision: reference.upstreamRevision, records,
|
||||
}, null, 2) + '\n');
|
||||
console.log(`Wrote ${records.length} matched-viewport comparisons; no parity assertion.`);
|
||||
@@ -21,10 +21,16 @@ process.env.DB_ENCRYPTION_KEY = '';
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.LOG_LEVEL = 'warn';
|
||||
const tls = { key: fs.readFileSync(path.join(directory, 'key.pem')), cert: fs.readFileSync(path.join(directory, 'cert.pem')) };
|
||||
const idp = await startIdentityProvider(tls);
|
||||
process.env.OIDC_ISSUER = idp.issuer;
|
||||
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
||||
const idp = process.env.YUVOMI_TEST_DISABLE_OIDC === 'true' ? null : await startIdentityProvider(tls);
|
||||
if (idp) {
|
||||
process.env.OIDC_ISSUER = idp.issuer;
|
||||
process.env.OIDC_CLIENT_ID = idp.clientId;
|
||||
process.env.OIDC_CLIENT_SECRET = idp.secret;
|
||||
} else {
|
||||
delete process.env.OIDC_ISSUER;
|
||||
delete process.env.OIDC_CLIENT_ID;
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
}
|
||||
const { server } = await import(pathToFileURL(path.join(upstream, 'server/index.js')));
|
||||
if (!server.listening) await once(server, 'listening');
|
||||
let networkAvailable = true;
|
||||
@@ -50,7 +56,9 @@ const proxy = https.createServer(tls, (request, response) => {
|
||||
});
|
||||
proxy.listen(0, '127.0.0.1');
|
||||
await once(proxy, 'listening');
|
||||
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
||||
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp.port }));
|
||||
process.on('SIGTERM', () => { idp.close(); proxy.close(); server.close(); process.exit(0); });
|
||||
if (idp) {
|
||||
process.env.OIDC_REDIRECT_URI = `https://127.0.0.1:${proxy.address().port}/api/v1/auth/oidc/callback`;
|
||||
idp.setRedirect(process.env.OIDC_REDIRECT_URI);
|
||||
}
|
||||
fs.writeFileSync(path.join(directory, 'ready.json'), JSON.stringify({ port: proxy.address().port, idpPort: idp?.port ?? null }));
|
||||
process.on('SIGTERM', () => { idp?.close(); proxy.close(); server.close(); process.exit(0); });
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Regressions for AGP screenshot output collection, not visual parity tests."""
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -57,6 +58,42 @@ class ScreenshotEvidenceTests(unittest.TestCase):
|
||||
self.assertFalse((self.screenshots / image.name).exists())
|
||||
self.assertEqual(unrelated.read_text(), "preserve")
|
||||
|
||||
def gallery(self):
|
||||
parent = self.fixture().parent
|
||||
for name in HARNESS.SCREENSHOTS:
|
||||
(parent / name).write_bytes(PNG)
|
||||
(parent / "viewport.json").write_text(json.dumps({"widthPx": 720, "heightPx": 1184, "density": 2}))
|
||||
return parent
|
||||
|
||||
def test_collects_complete_allowlisted_gallery(self):
|
||||
parent = self.gallery()
|
||||
(parent / "private.txt").write_text("not exported")
|
||||
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||
self.assertEqual({p.name for p in self.screenshots.iterdir()}, set(HARNESS.VISUAL_OUTPUTS))
|
||||
|
||||
def test_partial_gallery_is_not_exported(self):
|
||||
parent = self.gallery()
|
||||
(parent / "notes-dark.png").unlink()
|
||||
with self.assertRaisesRegex(AssertionError, "Missing or ambiguous"):
|
||||
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||
self.assertFalse(self.screenshots.exists())
|
||||
|
||||
def test_clears_every_known_gallery_file_but_preserves_unrelated(self):
|
||||
parent = self.gallery()
|
||||
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||
(parent / "other.png").write_bytes(PNG)
|
||||
HARNESS.prepare_android_screenshots(self.output)
|
||||
self.assertEqual([p.name for p in parent.iterdir()], ["other.png"])
|
||||
self.assertEqual(list(self.screenshots.iterdir()), [])
|
||||
|
||||
def test_rejects_gallery_split_across_devices(self):
|
||||
parent = self.gallery()
|
||||
other = parent.with_name("second")
|
||||
other.mkdir()
|
||||
(parent / "notes-dark.png").rename(other / "notes-dark.png")
|
||||
with self.assertRaisesRegex(AssertionError, "Mixed-device"):
|
||||
HARNESS.collect_native_screenshot(self.additional, self.screenshots, gallery=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+27
-11
@@ -20,25 +20,41 @@ import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REVISION = "10bfef7686ed7303b4c74c7fcc55a5eb16356c9d"
|
||||
SCREENSHOTS = ("native-dashboard.png",) + tuple(
|
||||
f"{page}-{theme}.png" for page in ("login", "dashboard", "tasks", "notes", "shopping") for theme in ("light", "dark"))
|
||||
VISUAL_OUTPUTS = SCREENSHOTS + ("viewport.json",)
|
||||
|
||||
|
||||
def prepare_android_screenshots(app_output):
|
||||
additional = app_output / "connected_android_test_additional_output/integrationAndroidTest"
|
||||
screenshots = app_output / "native-screenshots"
|
||||
# Never accept a previous run's screenshot as current evidence.
|
||||
for stale in additional.rglob("native-dashboard.png"):
|
||||
stale.unlink()
|
||||
(screenshots / "native-dashboard.png").unlink(missing_ok=True)
|
||||
for name in VISUAL_OUTPUTS:
|
||||
for stale in additional.rglob(name):
|
||||
stale.unlink()
|
||||
(screenshots / name).unlink(missing_ok=True)
|
||||
return additional, screenshots
|
||||
|
||||
|
||||
def collect_native_screenshot(additional, screenshots):
|
||||
captures = list(additional.rglob("native-dashboard.png"))
|
||||
assert len(captures) == 1, "Missing or ambiguous native fixture screenshot"
|
||||
capture = captures[0].read_bytes()
|
||||
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
|
||||
def collect_native_screenshot(additional, screenshots, gallery=False):
|
||||
names = VISUAL_OUTPUTS if gallery else ("native-dashboard.png",)
|
||||
validated = {}
|
||||
parents = set()
|
||||
for name in names:
|
||||
captures = list(additional.rglob(name))
|
||||
assert len(captures) == 1, f"Missing or ambiguous native fixture screenshot: {name}"
|
||||
capture = captures[0].read_bytes()
|
||||
if name.endswith(".png"):
|
||||
assert capture.startswith(b"\x89PNG\r\n\x1a\n"), "Invalid native fixture screenshot"
|
||||
else:
|
||||
viewport = json.loads(capture)
|
||||
assert viewport["widthPx"] > 0 and viewport["heightPx"] > 0 and viewport["density"] > 0
|
||||
parents.add(captures[0].parent)
|
||||
validated[name] = capture
|
||||
assert len(parents) == 1, "Mixed-device native gallery evidence"
|
||||
screenshots.mkdir(parents=True, exist_ok=True)
|
||||
(screenshots / "native-dashboard.png").write_bytes(capture)
|
||||
for name, capture in validated.items():
|
||||
(screenshots / name).write_bytes(capture)
|
||||
|
||||
|
||||
def run(upstream, android, cookie_without_secure=False):
|
||||
@@ -152,8 +168,8 @@ def run(upstream, android, cookie_without_secure=False):
|
||||
raise RuntimeError("Android integration tests failed. Inspect the Gradle test report.")
|
||||
reports = app_output / "androidTest-results/connected/integration"
|
||||
subprocess.run(["python3", str(ROOT / "scripts/verify-test-results.py"),
|
||||
str(reports), "--minimum", "7"], check=True)
|
||||
collect_native_screenshot(additional, screenshots)
|
||||
str(reports), "--minimum", "10"], check=True)
|
||||
collect_native_screenshot(additional, screenshots, gallery=True)
|
||||
finally:
|
||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{port}"], check=False)
|
||||
subprocess.run([str(adb), "reverse", "--remove", f"tcp:{ready['idpPort']}"], check=False)
|
||||
|
||||
Reference in new issue
Block a user