Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Pinned libetpan has every header MailCore 2 includes.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
prefix=$root/engines/libetpan
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-libetpan.sh" >"$work/build.log" 2>&1 || {
cat "$work/build.log" >&2
fail "build failed"
}
[ -s "$prefix/lib/libetpan.a" ] || fail "no libetpan.a"
config=$prefix/BUILD-config.h
grep -q '^#define HAVE_OPENSSL 1' "$config" || fail "libetpan built without TLS"
grep -q '^#define HAVE_JSON 1' "$config" || fail "libetpan built without json-c"
headers=$(grep -rhoE '#[[:space:]]*include[[:space:]]*<libetpan/[A-Za-z_0-9]+\.h>' "$root/reference/mailcore2/src" |
sed -E 's/.*<(libetpan\/[^>]+)>.*/\1/' | sort -u)
[ -n "$headers" ] || fail "MailCore 2 sources include no libetpan header"
for header in $headers; do
[ -f "$prefix/include/$header" ] || fail "pinned libetpan lacks $header"
done
echo "libetpan: $(wc -l <<<"$headers" | tr -d ' ') headers MailCore 2 includes are present"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Probes LOK on macOS: load, export, tile painting, thread rule.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
here=$root/tests/engines
expect=${ELY_LOK_EXPECT_TILE:-untouched}
frameworks=${ELY_LOK_DIR:-}
work=$(mktemp -d)
mount=
cleanup() {
if [ -n "$mount" ]; then
for _ in 1 2 3 4 5 6 7 8 9 10; do
if hdiutil detach "$mount" -quiet 2>/dev/null; then
mount=
break
fi
sleep 1
done
[ -z "$mount" ] || {
echo "FAIL: cannot detach $mount; left in place" >&2
exit 1
}
fi
rm -rf "$work"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
run() { SAL_FORCE_HIDPI_SCALING=1 SAL_SKIA=raster perl -e 'alarm 180; exec @ARGV' "$@"; }
case $expect in written | untouched) ;; *) fail "ELY_LOK_EXPECT_TILE is written or untouched, not $expect" ;; esac
[ "$(uname)" = Darwin ] || {
echo "SKIP lok: macOS only"
exit 0
}
if [ -z "$frameworks" ]; then
dmg=$(ls "$root"/engines/LibreOffice_*_MacOS_aarch64.dmg 2>/dev/null | head -n 1 || true)
[ -n "$dmg" ] || {
echo "SKIP lok: set ELY_LOK_DIR, or put the LibreOffice dmg in engines/"
exit 0
}
[ "$(stat -f %z "$dmg")" = 297798926 ] || fail "$dmg is not the pinned 26.2.6 aarch64 image"
mount=$work/mnt
mkdir "$mount"
hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg" >/dev/null
frameworks=$mount/LibreOffice.app/Contents/Frameworks
fi
exports=$(nm -gU "$frameworks/libmergedlo.dylib")
for symbol in _libreofficekit_hook _libreofficekit_hook_2 _lok_preinit; do
grep -qE " T $symbol\$" <<<"$exports" || fail "libmergedlo does not export $symbol"
done
plugins=$(ls "$frameworks" | grep '^libvclplug_' || true)
[ "$plugins" = libvclplug_osxlo.dylib ] || fail "VCL plugins are not only osx: $plugins"
python3 - "$work/hello.docx" <<'PY'
import sys, zipfile
types = '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
rels = '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'
body = '<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:b/><w:sz w:val="48"/></w:rPr><w:t>Ely Office 合同 Contract</w:t></w:r></w:p><w:p><w:r><w:t>The quick brown fox jumps over the lazy dog.</w:t></w:r></w:p></w:body></w:document>'
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", types)
z.writestr("_rels/.rels", rels)
z.writestr("word/document.xml", body)
PY
clang -O1 -I "$root/reference/libreoffice-core/include" "$here/lok_probe.c" -o "$work/probe" || fail "probe does not compile"
args=("$frameworks" "file://$work/profile" "file://$work/hello.docx" "file://$work/out.pdf")
out=$(run "$work/probe" "${args[@]}" unipoll 2>"$work/err") || fail "unipoll probe: $(tail -n 5 "$work/err")"
grep -q '"BuildId": "8221e31b3ac356a1623c672912a3d2b492f7e3d1"' <<<"$out" || fail "not the pinned build: $out"
grep -qE '^twips=[1-9][0-9]*x[1-9][0-9]*$' <<<"$out" || fail "document has no size: $out"
grep -qx 'pdf=1' <<<"$out" || fail "PDF export: $out"
[ -s "$work/out.pdf" ] || fail "no PDF written"
grep -qx 'polls=0' <<<"$out" || fail "Aqua called the poll callback: $out"
case $expect in
untouched) grep -qx 'painted=0' <<<"$out" || fail "tile painted, expected untouched: $out" ;;
written)
grep -qE '^painted=[1-9]' <<<"$out" || fail "tile untouched, expected painted: $out"
grep -qE '^ink=[1-9]' <<<"$out" || fail "tile has no text pixels: $out"
;;
esac
if run "$work/probe" "${args[@]}" threaded >/dev/null 2>"$work/err"; then fail "threaded mode did not crash"; fi
grep -q 'NSWindow should only be instantiated on the main thread' "$work/err" || fail "threaded crash has another cause: $(head -n 3 "$work/err")"
echo "lok: load, PDF export, thread rule pass; tile $expect"
+79
View File
@@ -0,0 +1,79 @@
#define LOK_USE_UNSTABLE_API
#include <LibreOfficeKit/LibreOfficeKitInit.h>
#include <dispatch/dispatch.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define PX 256
#define SENTINEL 0xAB
static LibreOfficeKit *lok;
static const char *doc_url, *pdf_url;
static volatile int polls;
static void probe(void) {
printf("version=%s\n", lok->pClass->getVersionInfo(lok));
LibreOfficeKitDocument *doc = lok->pClass->documentLoad(lok, doc_url);
if (!doc) {
printf("load=fail %s\n", lok->pClass->getError(lok));
exit(1);
}
doc->pClass->initializeForRendering(doc, NULL);
long w = 0, h = 0;
doc->pClass->getDocumentSize(doc, &w, &h);
printf("twips=%ldx%ld\npolls=%d\n", w, h, polls);
unsigned char *tile = malloc(PX * PX * 4);
memset(tile, SENTINEL, PX * PX * 4);
doc->pClass->paintTile(doc, tile, PX, PX, 0, 0, PX * 15, PX * 15);
long painted = 0, ink = 0;
for (long i = 0; i < PX * PX; i++) {
unsigned char *p = tile + i * 4;
if (p[0] != SENTINEL || p[1] != SENTINEL || p[2] != SENTINEL || p[3] != SENTINEL) painted++;
if (p[3] > 0 && p[0] < 128 && p[1] < 128 && p[2] < 128) ink++;
}
printf("painted=%ld\nink=%ld\npdf=%d\n", painted, ink, doc->pClass->saveAs(doc, pdf_url, "pdf", NULL));
fflush(stdout);
}
static int poll_cb(void *data, int timeout_us) {
polls++;
return 0;
}
static void wake_cb(void *data) {}
static void *post_probe(void *arg) {
sleep(1);
dispatch_async(dispatch_get_main_queue(), ^{
probe();
_exit(0);
});
return NULL;
}
int main(int argc, char **argv) {
if (argc != 6) {
fprintf(stderr, "usage: install profile-url doc-url pdf-url unipoll|threaded\n");
return 2;
}
doc_url = argv[3];
pdf_url = argv[4];
int unipoll = strcmp(argv[5], "unipoll") == 0;
if (unipoll) setenv("SAL_LOK_OPTIONS", "unipoll", 1);
lok = lok_init_2(argv[1], argv[2]);
if (!lok) {
fprintf(stderr, "lok_init_2 failed\n");
return 1;
}
if (!unipoll) {
probe();
return 0;
}
pthread_t thread;
pthread_create(&thread, NULL, post_probe, NULL);
lok->pClass->runLoop(lok, poll_cb, wake_cb, NULL);
return 0;
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Builds MuPDF, renders a PDF page, extracts its text.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
out=$root/engines/mupdf-build
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mupdf.sh" >"$work/build.log" 2>&1 || fail "build failed, see $work/build.log"
python3 - "$work/hello.pdf" <<'PY'
import sys
stream = b"BT /F1 24 Tf 72 700 Td (Ely Office contract) Tj ET"
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(stream), stream),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
body, offsets = b"%PDF-1.4\n", []
for n, obj in enumerate(objs, 1):
offsets.append(len(body))
body += b"%d 0 obj\n%s\nendobj\n" % (n, obj)
xref = len(body)
body += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
body += b"".join(b"%010d 00000 n \n" % o for o in offsets)
body += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
open(sys.argv[1], "wb").write(body)
PY
clang -O1 -I "$root/reference/mupdf/include" "$(dirname "$0")/mupdf_probe.c" \
"$out/libmupdf.a" "$out/libmupdf-third.a" -lm -o "$work/probe" || fail "probe does not link"
result=$("$work/probe" "$work/hello.pdf") || fail "probe failed: $result"
grep -qx 'pages=1' <<<"$result" || fail "page count: $result"
grep -qx 'bounds=612x792' <<<"$result" || fail "page bounds: $result"
grep -qx 'pixmap=1224x1584' <<<"$result" || fail "pixmap size: $result"
grep -qE '^dark=[1-9][0-9]*$' <<<"$result" || fail "page renders blank: $result"
grep -qx 'text=Ely Office contract' <<<"$result" || fail "text extraction: $result"
nm -u "$out/libmupdf-pkcs7.a" 2>/dev/null | grep -q 'PKCS7_' || fail "signing helper is a stub: no OpenSSL PKCS7 symbols"
echo "mupdf: render, text and OpenSSL signing helper pass"
+30
View File
@@ -0,0 +1,30 @@
#include <mupdf/fitz.h>
#include <stdio.h>
int main(int argc, char **argv) {
fz_context *ctx = fz_new_context(NULL, NULL, FZ_STORE_DEFAULT);
if (argc != 2 || !ctx) return 2;
fz_register_document_handlers(ctx);
fz_try(ctx) {
fz_document *doc = fz_open_document(ctx, argv[1]);
fz_page *page = fz_load_page(ctx, doc, 0);
fz_rect box = fz_bound_page(ctx, page);
fz_pixmap *pix = fz_new_pixmap_from_page(ctx, page, fz_scale(2, 2), fz_device_rgb(ctx), 0);
long dark = 0;
for (long i = 0; i < (long)pix->w * pix->h; i++)
if (pix->samples[i * pix->n] < 128) dark++;
printf("pages=%d\nbounds=%.0fx%.0f\npixmap=%dx%d\ndark=%ld\n", fz_count_pages(ctx, doc),
box.x1 - box.x0, box.y1 - box.y0, pix->w, pix->h, dark);
fz_stext_page *text = fz_new_stext_page_from_page(ctx, page, NULL);
fz_buffer *buf = fz_new_buffer(ctx, 256);
fz_output *out = fz_new_output_with_buffer(ctx, buf);
fz_print_stext_page_as_text(ctx, out, text);
fz_close_output(ctx, out);
printf("text=%.*s\n", (int)fz_buffer_storage(ctx, buf, NULL), fz_string_from_buffer(ctx, buf));
}
fz_catch(ctx) {
fprintf(stderr, "mupdf: %s\n", fz_caught_message(ctx));
return 1;
}
return 0;
}
+36
View File
@@ -0,0 +1,36 @@
# Title
Text with $e^{i\pi} + 1 = 0$ inline and a note.[^1]
$$I = \int \rho R^{2} dV$$
- [x] done
- [ ] open
| a | b |
| - | - |
| 1 | 2 |
```rust
fn main() {}
```
$$
\int_0^1 x\,dx
$$
$$
\frac{1}{
$$
```mermaid
graph TD
A --> B
```
```mermaid
graph TD
A -->
```
[^1]: The note.
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
雷电芽衣 ,zacharyzhang,leidianyayis-Mac-mini.local,08.10.2026 20:49,file:///private/var/folders/g4/sj7njkr93jz0rgmg0b_zqnvr0000gn/T/tmp.mPypHxNqRv/home-ely-doc/Library/Application%20Support/ElyOffice/ely-doc/lok-profile;
+1
View File
@@ -0,0 +1 @@
雷电芽衣 ,zacharyzhang,leidianyayis-Mac-mini.local,08.10.2026 20:50,file:///private/var/folders/g4/sj7njkr93jz0rgmg0b_zqnvr0000gn/T/tmp.rkBzVqQEZC/home-ely-show/Library/Application%20Support/ElyOffice/ely-show/lok-profile;
+1
View File
@@ -0,0 +1 @@
雷电芽衣 ,zacharyzhang,leidianyayis-Mac-mini.local,08.10.2026 20:50,file:///private/var/folders/g4/sj7njkr93jz0rgmg0b_zqnvr0000gn/T/tmp.rkBzVqQEZC/home-ely-table/Library/Application%20Support/ElyOffice/ely-table/lok-profile;
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:font-face-decls><style:font-face style:name="Nowhere Sans" svg:font-family="'Nowhere Sans'"/></office:font-face-decls>
<office:automatic-styles>
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
</office:automatic-styles>
<office:body><office:presentation>
<draw:page draw:name="One"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p><text:span text:style-name="T1">Lost font</text:span></text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="Two"><draw:frame draw:name="Gone" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:image xlink:href="file:///nowhere/gone.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></draw:page>
<draw:page draw:name="Three"><draw:frame draw:name="Empty" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:object xlink:href="./Object 9" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></draw:page>
<draw:page draw:name="Four"><draw:frame draw:name="Bad" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:image xlink:href="broken.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame><draw:frame draw:name="Junk" svg:x="9cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:image><office:binary-data>bm90IGFuIGltYWdlIGF0IGFsbA==</office:binary-data></draw:image></draw:frame></draw:page>
<draw:page draw:name="Five"><draw:frame draw:name="Linked" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:object xlink:href="linked.odt" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></draw:page>
</office:presentation></office:body>
</office:document>
+2
View File
@@ -0,0 +1,2 @@
// Outside the content folder; it must never run.
window.outsideRan = true;
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { background: var(--ely-bg); color: var(--ely-fg); margin: 0; }
textarea { width: 90%; height: 200px; }
</style>
</head>
<body>
<div id="marker" style="width: 300px; height: 150px; background: rgb(255, 0, 170)"></div>
<textarea id="text" aria-label="Text"></textarea>
<script src="../web-outside/evil.js"></script>
<script>
// The probe asks; the page answers through elyQuery.
window.report = (step) => window.elyQuery({
request: JSON.stringify({
step,
value: document.getElementById("text").value,
focused: document.activeElement && document.activeElement.id,
fg: getComputedStyle(document.documentElement).getPropertyValue("--ely-fg").trim(),
ratio: window.devicePixelRatio,
outside: Boolean(window.outsideRan),
rtc: typeof window.RTCPeerConnection,
}),
onSuccess: () => {},
onFailure: (code, message) => console.error("probe query failed", code, message),
});
document.getElementById("text").focus();
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""PERF-01 to PERF-08 on release builds, written as a record."""
import argparse
import datetime
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import time
import samples
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
APPS = {"ely-doc": "ElyDoc", "ely-table": "ElyTable", "ely-show": "ElyShow",
"ely-pdf": "ElyPDF", "ely-tree": "ElyTree", "ely-mark": "ElyMark"}
TARGETS = {"PERF-01": 3000, "PERF-02": 100, "PERF-03": 5000, "PERF-04": 5000,
"PERF-05": 3000, "PERF-06": 1000, "PERF-07": 2000}
def sh(*args, **kw):
return subprocess.run(args, check=True, capture_output=True, text=True, **kw).stdout
def tools(folder):
for tool in ["ax-values", "ax-press", "click"]:
sh("swiftc", "-O", "-o", os.path.join(folder, tool), os.path.join(ROOT, "tests", "scripts", tool + ".swift"))
class App:
"""A release app run, timed from spawn, read via log."""
def __init__(self, slug, home, args=(), smoke="hold"):
self.slug, self.home, self.tools = slug, home, os.path.dirname(home)
self.log = os.path.join(home, "Library", "Application Support", "ElyOffice", slug, "ely.log")
# Starts may trim the log; each run starts fresh.
if os.path.exists(self.log):
os.remove(self.log)
self.seen = 0
env = dict(os.environ, HOME=home, ELY_SMOKE=smoke, ELY_LOCALE="en", ELY_NO_DIALOG="1",
ELY_LOG="debug", ELY_STALLS="1")
self.start = time.monotonic()
self.process = subprocess.Popen([os.path.join(ROOT, "target", "release", slug), *args], env=env,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
def lines(self):
if not os.path.exists(self.log):
return []
with open(self.log, errors="replace") as log:
log.seek(self.seen)
return log.read().splitlines()
def wait(self, pattern, after=None, timeout=60):
"""Milliseconds from spawn until a line matches, past `after`."""
called = time.monotonic()
while time.monotonic() - called < timeout:
lines = self.lines()
if after is not None:
at = next((i for i, line in enumerate(lines) if re.search(after, line)), None)
lines = [] if at is None else lines[at + 1:]
if any(re.search(pattern, line) for line in lines):
return (time.monotonic() - self.start) * 1000
time.sleep(0.01)
tail = "\n".join(self.lines()[-12:])
raise RuntimeError(f"{self.slug}: no {pattern!r} in {timeout} s; the log ends:\n{tail}")
def ready(self, timeout=60):
"""ELY_SMOKE=1: milliseconds until the first frame."""
out, _ = self.process.communicate(timeout=timeout)
took = (time.monotonic() - self.start) * 1000
if f"ready {APPS[self.slug]}" not in out:
raise RuntimeError(f"{self.slug} did not report ready: {out!r}")
# Its engine may still be starting; the next run waits.
for _ in range(300):
if subprocess.run(["pgrep", "-f", self.home], capture_output=True).returncode != 0:
break
time.sleep(0.05)
return took
def family(self):
out = sh("ps", "-A", "-o", "pid=,ppid=,rss=")
rows = [tuple(map(int, line.split())) for line in out.splitlines()]
ours, grew = {self.process.pid}, True
while grew:
grew = False
for pid, ppid, _ in rows:
if ppid in ours and pid not in ours:
ours.add(pid)
grew = True
return ours, sum(rss for pid, _, rss in rows if pid in ours)
def memory_mb(self):
return self.family()[1] / 1024
def files(self):
ours, _ = self.family()
return sum(len(sh("lsof", "-p", str(pid)).splitlines()) for pid in ours)
def front(self):
sh("osascript", "-e", f'tell application "System Events" to set frontmost of (first process whose unix id is {self.process.pid}) to true')
time.sleep(0.5)
def ax(self):
return sh(os.path.join(self.tools, "ax-values"), str(self.process.pid))
def stop(self):
ours, _ = self.family()
for pid in ours:
try:
os.kill(pid, 9)
except ProcessLookupError:
pass
self.process.wait()
def keys(script):
sh("osascript", "-e", 'tell application "System Events"', "-e", script, "-e", "end tell")
def percentile(values, p):
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(len(ordered) * p))]
# A page that failed to paint voids the run.
PAINT_FAILED = r"lok: tile \(.*\) failed|pdf: drawing a page failed"
def opened(slug, home, sample, marks, runs):
"""Spawn with a file until `marks` all appear, in order."""
times, memory = [], []
for _ in range(runs):
app = App(slug, home, [sample])
try:
after = None
for mark in marks:
took = app.wait(mark, after)
after = mark
failed = [line for line in app.lines() if re.search(PAINT_FAILED, line)]
if failed:
raise RuntimeError(f"{slug}: {sample} did not paint: {failed[0]}")
times.append(took)
memory.append(app.memory_mb())
finally:
app.stop()
return times, memory
def laid_out(work, sample, pages):
"""The kernel lays `sample` out to exactly `pages` pages."""
app = App("ely-doc", os.path.join(work, "home-pages"), [sample])
try:
app.wait(rf"lok: {pages} pages$", timeout=120)
time.sleep(3)
counts = [line for line in app.lines() if re.search(r"lok: \d+ pages$", line)]
if not counts[-1].endswith(f" {pages} pages"):
raise RuntimeError(f"{sample} lays out as {counts[-1]}, not {pages} pages")
finally:
app.stop()
def perf01(work, runs):
rows = []
for slug in APPS:
home = os.path.join(work, "home-" + slug)
first = App(slug, home, smoke="1").ready()
daily = [App(slug, home, smoke="1").ready() for _ in range(runs)]
rows.append((slug, first, daily))
return rows
def perf02(work, runs):
"""Key to new tile in ElyDoc, and main-thread stalls meanwhile."""
app = App("ely-doc", os.path.join(work, "home-typing"))
try:
app.wait("opened Untitled")
app.front()
tick = r"lok: edit to tile (\d+) ms"
for n in range(runs):
keys('keystroke "x"')
# Each key must show; a lost one fails the run.
for _ in range(500):
if sum(1 for line in app.lines() if re.search(tick, line)) > n:
break
time.sleep(0.01)
else:
raise RuntimeError(f"ely-doc: key {n + 1} of {runs} never reached a tile")
time.sleep(0.15)
lines = app.lines()
finally:
app.stop()
ticks = [int(m.group(1)) for line in lines if (m := re.search(tick, line))]
if len(ticks) != runs:
raise RuntimeError(f"ely-doc: {runs} keys gave {len(ticks)} timings")
stalls = [int(m.group(1)) for line in lines if (m := re.search(r"main thread was busy (\d+) ms", line))]
return ticks, stalls
def perf06(work, repo, runs):
"""History first screen, then a common query's first results."""
home = os.path.join(work, "home-ely-tree")
first, found = [], []
for _ in range(runs):
app = App("ely-tree", home, [repo])
try:
app.wait("tree: opened")
app.front()
pressed = (time.monotonic() - app.start) * 1000
sh(os.path.join(work, "ax-press"), str(app.process.pid), "History")
first.append(app.wait(r"history shows \d+ commits, searched false") - pressed)
field = next(line for line in app.ax().splitlines() if line.startswith("AXTextField"))
x, y = field.rsplit("at=", 1)[1].split(",")
sh(os.path.join(work, "click"), x, y)
keys('keystroke "part 42"')
asked = (time.monotonic() - app.start) * 1000
keys("key code 36")
found.append(app.wait(r"searched true") - asked)
finally:
app.stop()
return first, found
def raise_main(app):
"""The document window as key, once a dialog closed."""
sh("osascript", "-e", f'tell application "System Events" to tell (first process whose unix id is {app.process.pid}) '
f'to perform action "AXRaise" of window "{APPS[app.slug]}"')
time.sleep(0.3)
def perf08(work, cycles=40):
"""New and close a document; memory, handles and live documents."""
app = App("ely-doc", os.path.join(work, "home-cycles"))
docs = r"lok-worker: (\d+) documents open"
def open_docs():
counts = [int(m.group(1)) for line in app.lines() if (m := re.search(docs, line))]
return counts[-1] if counts else None
try:
app.wait("opened Untitled")
app.front()
time.sleep(2)
baseline = (app.memory_mb(), app.files(), open_docs())
seen = []
for n in range(cycles):
keys('keystroke "n" using command down')
for _ in range(40):
if "title=Use template" in app.ax():
break
time.sleep(0.25)
sh(os.path.join(work, "ax-press"), str(app.process.pid), "Use template", "AXButton")
app.wait(f"opened Untitled {n + 2} ", timeout=30)
time.sleep(1)
# A keystroke can miss a window still taking focus.
for _ in range(3):
app.front()
raise_main(app)
keys('keystroke "w" using command down')
try:
app.wait(f"office: closed Untitled {n + 2}$", timeout=5)
break
except RuntimeError:
# A keystroke that lost its Command edits the document.
if "title=Don't Save" in app.ax():
raise RuntimeError(f"ely-doc: Untitled {n + 2} was edited by a stray key; it asks to save")
continue
else:
said = [line for line in app.lines() if re.search("clos|dialog|opened|focus", line)]
raise RuntimeError(f"ely-doc: Untitled {n + 2} never closed: {said[-8:]}")
# The kernel lets go in the background.
for _ in range(100):
if open_docs() == baseline[2]:
break
time.sleep(0.05)
time.sleep(0.5)
seen.append((app.memory_mb(), app.files(), open_docs()))
finally:
app.stop()
# The first dialog loads system fonts and AppKit tables once.
first, last = seen[0], seen[-1]
if last[1] != first[1] or last[2] != baseline[2]:
raise RuntimeError(f"ely-doc: after {cycles} cycles {last[1:]} files and documents, "
f"not {first[1]} and {baseline[2]}")
return baseline, seen
def environment(found):
gpu = re.findall(r"Chipset Model: (.+)", sh("system_profiler", "SPDisplaysDataType"))
fonts = sum(len(files) for folder in ["/System/Library/Fonts", "/Library/Fonts"] for _, _, files in os.walk(folder))
builds = {slug: hashlib.sha256(open(os.path.join(ROOT, "target", "release", slug), "rb").read()).hexdigest()[:16]
for slug in APPS}
return {
"date": datetime.date.today().isoformat(),
"machine": sh("sysctl", "-n", "hw.model").strip(),
"cpu": sh("sysctl", "-n", "machdep.cpu.brand_string").strip(),
"cores": sh("sysctl", "-n", "hw.ncpu").strip(),
"memory_gb": int(sh("sysctl", "-n", "hw.memsize")) // 2**30,
"gpu": ", ".join(gpu),
"system": f"macOS {platform.mac_ver()[0]}",
"fonts": fonts,
"builds": builds,
"samples": {name: value[1] for name, value in found.items()},
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--runs", type=int, default=30)
parser.add_argument("--only", nargs="*")
parser.add_argument("--out", default=os.path.join(ROOT, "docs", "perf"))
args = parser.parse_args()
want = lambda perf: not args.only or perf in args.only
if sys.platform != "darwin":
sys.exit("SKIP: the bench drives macOS windows")
sh("cargo", "build", "--release", "-q", "--bins", "-p", "ely-lok", "-p", "ely-pdf-engine",
*[arg for slug in APPS for arg in ("-p", slug)], cwd=ROOT)
git = os.path.join(ROOT, "engines", "git", "bin", "git")
found = samples.made(os.path.join(ROOT, "engines", "bench"), git)
work = tempfile.mkdtemp()
tools(work)
rows = []
try:
if want("PERF-01"):
for slug, first, daily in perf01(work, args.runs):
rows.append(("PERF-01", f"{slug} first run", [first], None))
rows.append(("PERF-01", f"{slug} daily start", daily, None))
# The window opens first; the blank document follows the engine.
for slug in ["ely-doc", "ely-table", "ely-show"]:
blank = []
for _ in range(args.runs):
app = App(slug, os.path.join(work, "home-" + slug))
try:
blank.append(app.wait("opened Untitled"))
finally:
app.stop()
rows.append(("PERF-01", f"{slug} blank document ready", blank, None))
if want("PERF-02"):
ticks, stalls = perf02(work, args.runs)
rows.append(("PERF-02", "ElyDoc key to new tile", ticks, None))
rows.append(("PERF-02", f"main thread busy >100 ms: {len(stalls)} times", stalls or [0], None))
cases = [("PERF-03", "ely-doc", "doc.docx", ["opened doc.docx", r"lok: tile \(\d+, \d+\) shown"]),
("PERF-04", "ely-table", "sheet.xlsx", ["opened sheet.xlsx", r"lok: tile \(\d+, \d+\) shown"]),
("PERF-05", "ely-pdf", "pages.pdf", ["opened pages.pdf", r"pdf: a tile of page 0 drawn"]),
("PERF-07", "ely-mark", "notes.md", ["opened notes.md", r"mark: shown as \"visual\""])]
if want("PERF-03"):
laid_out(work, found["doc.docx"][0], 100)
for perf, slug, name, marks in cases:
if want(perf):
times, memory = opened(slug, os.path.join(work, "home-" + slug), found[name][0], marks, args.runs)
rows.append((perf, f"{APPS[slug]} {name}", times, max(memory)))
if want("PERF-06"):
first, searched = perf06(work, found["repo"][0], args.runs)
rows.append(("PERF-06", "ElyTree history, press to first screen", first, None))
rows.append(("PERF-06", "ElyTree query 'part 42', first results", searched, None))
held = perf08(work) if want("PERF-08") else None
finally:
shutil.rmtree(work, ignore_errors=True)
write(args.out, environment(found), rows, held, args.runs)
def write(out, env, rows, held, runs):
os.makedirs(out, exist_ok=True)
path = os.path.join(out, f"{env['date']}-{env['machine']}.md")
lines = [f"# Benchmark {env['date']}", "", "Format: docs/perf/README.md.", "", "```json",
json.dumps(env, indent=2), "```", "",
"| PERF | Case | Runs | P50 ms | P95 ms | Target ms | Total memory MB |", "| --- | --- | --- | --- | --- | --- | --- |"]
for perf, case, times, memory in rows:
target = TARGETS.get(perf)
p95 = percentile(times, 0.95)
verdict = "" if target is None or "busy" in case else (" ✓" if p95 <= target else " ✗")
mem = "" if memory is None else f"{memory:.0f}"
lines.append(f"| {perf} | {case} | {len(times)} | {percentile(times, 0.5):.0f} | {p95:.0f}{verdict} | "
f"{target or ''} | {mem} |")
if held:
(memory, files, docs), seen = held
lines += ["", f"PERF-08, ElyDoc creating and closing a document {len(seen)} times "
"(total memory MB / open files / kernel documents):", "",
f"before: {memory:.0f}/{files}/{docs}", "",
" ".join(f"{memory:.0f}/{files}/{docs}" for memory, files, docs in seen)]
lines += ["", "PERF-09 (Agent sessions) belongs to the second release.", ""]
with open(path, "w") as record:
record.write("\n".join(lines))
print(path)
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
"""Benchmark samples, made the same way every time (§20.1)."""
import hashlib
import os
import random
import struct
import subprocess
import zipfile
import zlib
def noise_png(seed, width, height):
"""An RGB PNG of seeded noise; it does not compress."""
rng = random.Random(seed)
rows = b"".join(b"\0" + rng.randbytes(width * 3) for _ in range(height))
def chunk(kind, body):
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body))
head = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", head) + chunk(b"IDAT", zlib.compress(rows, 1)) + chunk(b"IEND", b"")
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' \
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' \
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ' \
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' \
'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"'
def picture(n):
cx, cy = 3600000, 2700000
return (f'<w:p><w:r><w:drawing><wp:inline><wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{n}" name="p{n}"/>'
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
f'<pic:pic><pic:nvPicPr><pic:cNvPr id="{n}" name="p{n}.png"/><pic:cNvPicPr/></pic:nvPicPr>'
f'<pic:blipFill><a:blip r:embed="img{n}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
f'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>'
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic>'
'</wp:inline></w:drawing></w:r></w:p>')
def docx(path):
"""PERF-03: 100 pages, 20 images, under 10 MB."""
rng = random.Random(3)
words = "office local file engine page table slide note draft review".split()
body = []
for page in range(100):
# The break runs in the heading; Heading 1 drops pageBreakBefore.
breaks = '<w:r><w:br w:type="page"/></w:r>' if page else ""
body.append(f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>{breaks}<w:r><w:t>Page {page + 1}</w:t></w:r></w:p>')
for _ in range(6):
text = " ".join(rng.choice(words) for _ in range(60))
body.append(f"<w:p><w:r><w:t>{text}</w:t></w:r></w:p>")
if page % 5 == 0:
body.append(picture(page // 5 + 1))
rels = "".join(f'<Relationship Id="img{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
f'Target="media/p{n}.png"/>' for n in range(1, 21))
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>')
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
archive.writestr("word/_rels/document.xml.rels", f'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>')
archive.writestr("word/document.xml", f'<w:document {W}><w:body>{"".join(body)}</w:body></w:document>')
for n in range(1, 21):
archive.writestr(f"word/media/p{n}.png", noise_png(n, 400, 300), zipfile.ZIP_STORED)
def xlsx(path):
"""PERF-04: 100,000 values and 10,000 simple formulas."""
rows = []
for r in range(1, 10001):
cells = "".join(f'<c r="{chr(65 + c)}{r}"><v>{(r * 7 + c * 13) % 1000}</v></c>' for c in range(10))
rows.append(f'<row r="{r}">{cells}<c r="K{r}"><f>SUM(A{r}:J{r})</f></c></row>')
ns = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' \
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
archive.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>')
archive.writestr("xl/workbook.xml", f'<workbook {ns}><sheets><sheet name="Data" sheetId="1" r:id="s1"/></sheets></workbook>')
archive.writestr("xl/worksheets/sheet1.xml", f'<worksheet {ns}><sheetData>{"".join(rows)}</sheetData></worksheet>')
def pdf(path):
"""PERF-05: 1000 pages, about 200 MB, one image a page."""
rng = random.Random(5)
offsets, out = [], open(path, "wb")
out.write(b"%PDF-1.7\n")
def obj(n, body):
offsets.append((n, out.tell()))
out.write(f"{n} 0 obj\n".encode() + body + b"\nendobj\n")
pages = 1000
kids = " ".join(f"{3 + i * 3} 0 R" for i in range(pages))
obj(1, b"<< /Type /Catalog /Pages 2 0 R >>")
obj(2, f"<< /Type /Pages /Kids [{kids}] /Count {pages} >>".encode())
for i in range(pages):
page, content, image = 3 + i * 3, 4 + i * 3, 5 + i * 3
pixels = rng.randbytes(240 * 280 * 3)
obj(page, f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {content} 0 R "
f"/Resources << /XObject << /I {image} 0 R >> /Font << /F <</Type /Font /Subtype /Type1 /BaseFont /Helvetica>> >> >> >>".encode())
draw = f"q 480 0 0 560 66 160 cm /I Do Q BT /F 24 Tf 72 100 Td (Page {i + 1}) Tj ET".encode()
obj(content, f"<< /Length {len(draw)} >>\nstream\n".encode() + draw + b"\nendstream")
obj(image, f"<< /Type /XObject /Subtype /Image /Width 240 /Height 280 /ColorSpace /DeviceRGB "
f"/BitsPerComponent 8 /Length {len(pixels)} >>\nstream\n".encode() + pixels + b"\nendstream")
xref = out.tell()
count = 3 + pages * 3
places = dict(offsets)
out.write(f"xref\n0 {count}\n0000000000 65535 f \n".encode())
for n in range(1, count):
out.write(f"{places[n]:010d} 00000 n \n".encode())
out.write(f"trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode())
out.close()
def repo(path, git):
"""PERF-06: a repository of 100,000 commits."""
subprocess.run([git, "init", "-q", "-b", "main", path], check=True)
stream = []
for n in range(1, 100001):
message = f"Change {n}: {'fix' if n % 3 else 'add'} part {n % 97}\n".encode()
body = f"line {n}\n".encode()
stream.append(b"commit refs/heads/main\n")
stream.append(f"committer Bench <bench@ely.local> {1700000000 + n} +0000\n".encode())
stream.append(f"data {len(message)}\n".encode() + message)
stream.append(f"M 644 inline part{n % 97}.txt\ndata {len(body)}\n".encode() + body)
subprocess.run([git, "-C", path, "fast-import", "--quiet"], input=b"".join(stream), check=True)
subprocess.run([git, "-C", path, "checkout", "-q", "main"], check=True)
def markdown(path):
"""PERF-07: a 1 MB Markdown document."""
rng = random.Random(7)
words = "local file engine page table slide note draft review commit".split()
parts, size, n = [], 0, 0
while size < 1024 * 1024:
n += 1
block = f"## Section {n}\n\n" + " ".join(rng.choice(words) for _ in range(120)) + \
f"\n\n- item {n}\n- **bold** and `code`\n\n```rust\nfn f{n}() -> u32 {{ {n} }}\n```\n\n"
parts.append(block)
size += len(block)
with open(path, "w") as out:
out.write("".join(parts))
def made(folder, git):
"""Each sample, made once, with its SHA-256."""
os.makedirs(folder, exist_ok=True)
makers = {"doc.docx": docx, "sheet.xlsx": xlsx, "pages.pdf": pdf, "notes.md": markdown}
found = {}
for name, make in makers.items():
path = os.path.join(folder, name)
if not os.path.exists(path):
make(path)
found[name] = (path, hashlib.sha256(open(path, "rb").read()).hexdigest())
path = os.path.join(folder, "repo")
if not os.path.exists(path):
repo(path, git)
head = subprocess.run([git, "-C", path, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
found["repo"] = (path, head)
return found
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Long editing and switching in ElyDoc, checked as it goes."""
import argparse
import os
import shutil
import sys
import tempfile
import time
import zipfile
import xml.etree.ElementTree as ET
import bench
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def words(path):
"""The saved text, read without LibreOffice; a broken file raises."""
with zipfile.ZipFile(path) as archive:
body = ET.fromstring(archive.read("word/document.xml"))
# Runs join without spaces; a word may span two.
paragraphs = ("".join(node.text or "" for node in p.iter(W + "t")) for p in body.iter(W + "p"))
return " ".join(paragraphs).split()
def check(paths, n):
"""What the saved files get wrong after `n` cycles."""
a, b = words(paths["a.docx"]), words(paths["b.docx"])
problems = [f"a lacks A{k}" for k in range(1, n + 1) if f"A{k}" not in a]
problems += [f"b keeps B{k}" for k in range(1, n + 1) if f"B{k}" in b]
if any(word.startswith("A") and word[1:].isdigit() for word in b):
problems.append("b holds a's text")
return problems
def saves(app):
"""The app's last save lines, for a failure."""
return [line[-90:] for line in app.lines() if "save" in line][-6:]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--minutes", type=float, default=480)
args = parser.parse_args()
if sys.platform != "darwin":
sys.exit("SKIP: the soak drives macOS windows")
work = tempfile.mkdtemp()
bench.tools(work)
fixture = os.path.join(bench.ROOT, "tests", "fixtures", "office", "t.docx")
paths = {name: os.path.join(work, name) for name in ["a.docx", "b.docx"]}
for path in paths.values():
shutil.copy(fixture, path)
app = bench.App("ely-doc", os.path.join(work, "home"), list(paths.values()))
def save():
"""Saves through the menu: a keystroke can be lost."""
bench.sh("osascript", "-e", f'tell application "System Events" to tell (first process whose unix id is {app.process.pid}) '
'to click menu item "Save" of menu "File" of menu bar 1')
def press(title):
"""Chooses a tab, then puts the keyboard in its document."""
bench.sh(os.path.join(work, "ax-press"), str(app.process.pid), title, "AXRadioButton")
time.sleep(0.5)
area = next(line for line in app.ax().splitlines() if line.startswith("AXTextArea"))
x, y = area.rsplit("at=", 1)[1].split(",")
bench.sh(os.path.join(work, "click"), x, y)
# The click may land mid-word; typing goes at the end.
bench.keys("key code 119 using command down")
time.sleep(0.3)
try:
app.wait("opened b.docx")
app.front()
end, n, memory = time.monotonic() + args.minutes * 60, 0, []
while time.monotonic() < end:
n += 1
press("a.docx")
bench.keys(f'keystroke " A{n} "')
save()
press("b.docx")
bench.keys(f'keystroke "B{n}"')
time.sleep(0.3)
bench.keys('keystroke "z" using command down')
save()
time.sleep(0.5)
if n % 10 == 0:
# Saves finish in their own time; give them ten seconds.
for _ in range(40):
problems = check(paths, n)
if not problems:
break
time.sleep(0.25)
if problems:
raise RuntimeError(f"cycle {n}: {problems[:5]}; a ends {words(paths['a.docx'])[-8:]}; log: {saves(app)}")
memory.append(app.memory_mb())
print(f"cycle {n}: ok, {memory[-1]:.0f} MB", flush=True)
finally:
app.stop()
shutil.rmtree(work, ignore_errors=True)
print(f"ok: {n} cycles in {args.minutes} min; memory MB {memory[0]:.0f} → {memory[-1]:.0f}")
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Every AppImage tool the recipe downloads matches its pinned hash.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
script=$root/packaging/linux/appimage.sh
fail() {
echo "FAIL: $*" >&2
exit 1
}
pack=https://github.com/AppImage/appimagetool/releases/download/1.9.1
deploy=https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20251107-1
runtime=https://github.com/AppImage/type2-runtime/releases/download/20251108
curl -fsSI --max-time 20 "$pack/appimagetool-x86_64.AppImage" >/dev/null 2>&1 || {
echo "SKIP appimage-pins: GitHub is not reachable"
exit 0
}
grep -q "$pack/appimagetool-\$arch.AppImage" "$script" || fail "the script downloads another appimagetool"
grep -q "$deploy/linuxdeploy-\$arch.AppImage" "$script" || fail "the script downloads another linuxdeploy"
grep -q "$runtime/runtime-\$arch" "$script" || fail "the script downloads another runtime"
grep -q -- '--runtime-file "\$runtime"' "$script" || fail "appimagetool fetches its own runtime"
for arch in x86_64 aarch64; do
for url in "$pack/appimagetool-$arch.AppImage" "$deploy/linuxdeploy-$arch.AppImage" "$runtime/runtime-$arch"; do
sha=$(curl -fsSL "$url" | shasum -a 256 | cut -d' ' -f1)
grep -q "$sha" "$script" || fail "$url has sha256 $sha, which the script does not pin"
done
done
echo "appimage-pins: 6 downloads match their pins"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Each app opens, draws a frame, quits, and logs.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
cargo build -q --workspace --manifest-path "$root/Cargo.toml"
for entry in ElyDoc:ely-doc ElyTable:ely-table ElyShow:ely-show ElyPDF:ely-pdf ElyMail:ely-mail ElyTree:ely-tree ElyMark:ely-mark ElyAgent:ely-agent; do
name=${entry%%:*}
slug=${entry##*:}
out=$(ELY_SMOKE=1 HOME="$work" XDG_DATA_HOME="$work/data" perl -e 'alarm 60; exec @ARGV' "$root/target/debug/$slug" 2>"$work/$slug.err") ||
fail "$slug did not exit cleanly: $(tail -n 5 "$work/$slug.err")"
[ "$out" = "ready $name" ] || fail "$slug printed '$out', expected 'ready $name'"
log=$(find "$work" -path "*/ElyOffice/$slug/ely.log" | head -n 1)
[ -n "$log" ] || fail "$slug wrote no ElyOffice/$slug/ely.log"
grep -q "$name $version started" "$log" || fail "$slug log lacks its start line"
grep -q "$name window open" "$log" || fail "$slug log lacks 'window open'"
done
echo "apps: 8 of 8 open a window, draw, and quit"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# ElyDoc autosave: off, then on from settings; outside changes wait.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
pkill -9 -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
[ "$status" = 0 ] && rm -rf "$work" || echo "kept $work" >&2
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press; do swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"; done
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
data="$home/Library/Application Support/ElyOffice/ely-doc"
mkdir -p "$home"
cp "$root/tests/fixtures/office/t.docx" "$work/a.docx"
cp "$work/a.docx" "$work/a.orig"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
seen() {
for _ in $(seq 80); do "$work/ax-values" "$pid" | grep -q -- "$1" && return 0; sleep 0.25; done
return 1
}
ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" perl -e 'alarm 240; exec @ARGV' \
"$root/target/debug/ely-doc" "$work/a.docx" >"$work/out" 2>&1 &
pid=$!
for _ in $(seq 150); do grep -q "opened a.docx" "$data/ely.log" 2>/dev/null && break; sleep 0.2; done
grep -q "opened a.docx" "$data/ely.log" || fail "a.docx did not open"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
# Off: edits stay unsaved.
keys 'keystroke "Zqx"'
sleep 6
cmp -s "$work/a.docx" "$work/a.orig" || fail "saved while autosave is off"
# On from the Documents settings.
keys 'keystroke "," using command down'
seen "title=Documents" || fail "no settings window"
"$work/ax-press" "$pid" Documents >/dev/null
seen "title=Save automatically" || fail "no autosave switch"
"$work/ax-press" "$pid" "Save automatically" AXCheckBox >/dev/null
for _ in $(seq 50); do grep -q '"autosave": true' "$data/settings.json" 2>/dev/null && break; sleep 0.2; done
grep -q '"autosave": true' "$data/settings.json" || fail "the switch is not kept"
osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid) to perform action \"AXRaise\" of window \"ElyDoc\"" >/dev/null
sleep 1
# The pending edit now saves once typing rests.
for _ in $(seq 40); do cmp -s "$work/a.docx" "$work/a.orig" || break; sleep 0.25; done
cmp -s "$work/a.docx" "$work/a.orig" && fail "no autosave with the switch on"
grep -q "autosaving tab" "$data/ely.log" || fail "the save was not the autosave"
# Changed elsewhere: the file waits for the user.
sleep 1
cp "$work/a.orig" "$work/a.docx"
keys 'keystroke "Y"'
sleep 6
cmp -s "$work/a.docx" "$work/a.orig" || fail "autosave wrote over an outside change"
echo "ok: autosave"
+47
View File
@@ -0,0 +1,47 @@
// Prints each window element's frame and parent as JSON.
import ApplicationServices
import Foundation
func attribute(_ element: AXUIElement, _ name: String) -> AnyObject? {
var value: AnyObject?
guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil }
return value
}
// The element's frame in screen points.
func frame(_ element: AXUIElement) -> CGRect? {
var origin = CGPoint.zero, size = CGSize.zero
guard let position = attribute(element, kAXPositionAttribute),
let extent = attribute(element, kAXSizeAttribute),
AXValueGetValue(position as! AXValue, .cgPoint, &origin),
AXValueGetValue(extent as! AXValue, .cgSize, &size) else { return nil }
return CGRect(origin: origin, size: size)
}
var next = 0
func walk(_ element: AXUIElement, _ parent: Int, _ depth: Int) {
if depth > 60 { return }
next += 1
let id = next
let role = attribute(element, kAXRoleAttribute) as? String ?? ""
let name = [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute]
.compactMap { attribute(element, $0) as? String }.first { !$0.isEmpty } ?? ""
var row: [String: Any] = ["id": id, "parent": parent, "role": role, "name": name]
if let rect = frame(element) {
row["frame"] = [rect.minX, rect.minY, rect.width, rect.height]
}
let data = try! JSONSerialization.data(withJSONObject: row)
print(String(data: data, encoding: .utf8)!)
for child in attribute(element, kAXChildrenAttribute) as? [AXUIElement] ?? [] {
walk(child, id, depth + 1)
}
}
guard AXIsProcessTrusted() else {
print("untrusted")
exit(2)
}
let app = AXUIElementCreateApplication(pid_t(CommandLine.arguments[1])!)
for window in attribute(app, kAXWindowsAttribute) as? [AXUIElement] ?? [] {
walk(window, 0, 0)
}
+35
View File
@@ -0,0 +1,35 @@
// Presses the first element named `title`, maybe of `role`.
import ApplicationServices
import Foundation
let pid = pid_t(CommandLine.arguments[1])!
let title = CommandLine.arguments[2]
let role = CommandLine.arguments.count > 3 ? CommandLine.arguments[3] : nil
guard AXIsProcessTrusted() else {
print("untrusted")
exit(0)
}
func attr(_ e: AXUIElement, _ n: String) -> AnyObject? {
var v: AnyObject?
return AXUIElementCopyAttributeValue(e, n as CFString, &v) == .success ? v : nil
}
func find(_ e: AXUIElement, _ depth: Int) -> AXUIElement? {
if depth > 40 { return nil }
let named = (attr(e, kAXTitleAttribute) as? String) == title || (attr(e, kAXDescriptionAttribute) as? String) == title
if named && (role == nil || (attr(e, kAXRoleAttribute) as? String) == role) {
return e
}
for child in attr(e, kAXChildrenAttribute) as? [AXUIElement] ?? [] {
if let found = find(child, depth + 1) { return found }
}
return nil
}
let app = AXUIElementCreateApplication(pid)
// Dialogs can be windows the app's children leave out.
let windows = attr(app, kAXWindowsAttribute) as? [AXUIElement] ?? []
let roots = [app] + windows
guard let target = roots.lazy.compactMap({ find($0, 0) }).first else {
print("none")
exit(0)
}
print(AXUIElementPerformAction(target, kAXPressAction as CFString) == .success ? "pressed" : "refused")
+38
View File
@@ -0,0 +1,38 @@
// Prints role, value, title and center of each AX element.
import ApplicationServices
import Foundation
func attribute(_ element: AXUIElement, _ name: String) -> AnyObject? {
var value: AnyObject?
guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil }
return value
}
// The element's center in screen points, or empty.
func center(_ element: AXUIElement) -> String {
var origin = CGPoint.zero, size = CGSize.zero
guard let position = attribute(element, kAXPositionAttribute),
let extent = attribute(element, kAXSizeAttribute),
AXValueGetValue(position as! AXValue, .cgPoint, &origin),
AXValueGetValue(extent as! AXValue, .cgSize, &size) else { return "" }
return "\(Int(origin.x + size.width / 2)),\(Int(origin.y + size.height / 2))"
}
func walk(_ element: AXUIElement, _ depth: Int) {
if depth > 40 { return }
let role = attribute(element, kAXRoleAttribute) as? String ?? ""
let value = attribute(element, kAXValueAttribute) as? String ?? ""
let title = attribute(element, kAXTitleAttribute) as? String ?? ""
let label = attribute(element, kAXDescriptionAttribute) as? String ?? ""
let help = attribute(element, kAXHelpAttribute) as? String ?? ""
print("\(role)\tvalue=\(value)\ttitle=\(title)\tlabel=\(label)\thelp=\(help)\tat=\(center(element))")
for child in attribute(element, kAXChildrenAttribute) as? [AXUIElement] ?? [] {
walk(child, depth + 1)
}
}
guard AXIsProcessTrusted() else {
print("untrusted")
exit(2)
}
walk(AXUIElementCreateApplication(pid_t(CommandLine.arguments[1])!), 0)
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# build-libetpan.sh rebuilds stale caches and shows build errors.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
src=$root/reference/libetpan
[ -d "$src/.git" ] || { echo "SKIP build-libetpan: reference/libetpan is not checked out"; exit 0; }
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
mkdir -p "$work/scripts" "$work/engines/libetpan/lib" "$work/reference" "$work/tests/engines"
cp "$root/scripts/build-libetpan.sh" "$work/scripts/"
cp "$root/tests/engines/libetpan.sh" "$work/tests/engines/"
ln -s "$src" "$work/reference/libetpan"
git -C "$src" rev-parse HEAD >"$work/engines/libetpan/PIN"
touch "$work/engines/libetpan/lib/libetpan.a"
: >"$work/engines/libetpan/BUILD-config.h"
if OPENSSL_DIR=/nonexistent "$work/scripts/build-libetpan.sh" >"$work/out" 2>&1; then
fail "a cached build without TLS was reused"
fi
grep -q "no OpenSSL headers under /nonexistent" "$work/out" || fail "the OpenSSL error was not shown"
if OPENSSL_DIR=/nonexistent "$work/tests/engines/libetpan.sh" >"$work/out" 2>&1; then
fail "the probe passed on a TLS-less cached build"
fi
grep -q "no OpenSSL headers under /nonexistent" "$work/out" || fail "the probe hid the build error"
printf '#define HAVE_OPENSSL 1\n#define HAVE_JSON 1\n' >"$work/engines/libetpan/BUILD-config.h"
OPENSSL_DIR=/nonexistent "$work/scripts/build-libetpan.sh" >"$work/out" 2>&1 || fail "a valid cache was not reused"
grep -q "^ok libetpan" "$work/out" || fail "a valid cache did not print ok"
echo "build-libetpan: stale caches are rebuilt, errors reach stderr, valid caches are reused"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# The macOS bundle is valid, signed, and starts.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ "$(uname)" = Darwin ] || {
echo "SKIP bundle-macos: macOS only"
exit 0
}
version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
app=$(ELY_DIST="$work" "$root/packaging/macos/bundle.sh" ely-pdf debug | tail -n 1)
[ "$app" = "$work/ElyPDF.app" ] || fail "bundle printed $app"
plutil -lint "$app/Contents/Info.plist" >/dev/null || fail "Info.plist is not valid"
plist() { /usr/libexec/PlistBuddy -c "Print :$1" "$app/Contents/Info.plist"; }
[ "$(plist CFBundleIdentifier)" = ely.zacharyzhang.office.pdf ] || fail "bundle id: $(plist CFBundleIdentifier)"
[ "$(plist CFBundleName)" = ElyPDF ] || fail "bundle name: $(plist CFBundleName)"
[ "$(plist CFBundleShortVersionString)" = "$version" ] || fail "version: $(plist CFBundleShortVersionString)"
codesign --verify --deep --strict "$app" || fail "signature does not verify"
out=$(ELY_SMOKE=1 HOME="$work" perl -e 'alarm 60; exec @ARGV' "$app/Contents/MacOS/ely-pdf" 2>/dev/null) ||
fail "the bundled binary did not exit cleanly"
[ "$out" = "ready ElyPDF" ] || fail "bundled binary printed '$out'"
if ELY_DIST="$work" "$root/packaging/macos/bundle.sh" ely-nope debug >/dev/null 2>&1; then
fail "an unknown app built"
fi
echo "bundle-macos: valid, signed, starts"
+10
View File
@@ -0,0 +1,10 @@
// Clicks the left button at x y in screen points.
import CoreGraphics
import Foundation
let point = CGPoint(x: Double(CommandLine.arguments[1])!, y: Double(CommandLine.arguments[2])!)
for kind in [CGEventType.leftMouseDown, .leftMouseUp] {
CGEvent(mouseEventSource: nil, mouseType: kind, mouseCursorPosition: point, mouseButton: .left)!
.post(tap: .cghidEventTap)
usleep(60_000)
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# comments.py fails on long and multi-line comments.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
check() { python3 "$root/tests/scripts/comments.py" "$work" >/dev/null 2>&1; }
mkdir -p "$work/apps/a/web/src" "$work/tests/scripts"
printf '// One short line.\nconst a = 1\n' >"$work/apps/a/web/src/a.js"
check || fail "a short comment failed"
printf '/* A comment\n spanning lines. */\n' >"$work/apps/a/web/src/a.js"
! check || fail "a multi-line JS block comment passed"
printf 'const a = 1 /* A comment\n spanning lines. */\n' >"$work/apps/a/web/src/a.js"
! check || fail "an inline multi-line JS block comment passed"
printf '// One two three four five six seven eight nine ten eleven.\n' >"$work/apps/a/web/src/a.js"
! check || fail "an eleven-word JS comment passed"
rm "$work/apps/a/web/src/a.js"
printf '// One two three four five six seven eight nine ten eleven.\n' >"$work/tests/scripts/a.swift"
! check || fail "an eleven-word Swift comment passed"
echo "ok: comment rules"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Comments stay on one line and under eleven words."""
import re
import sys
from pathlib import Path
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parents[2]
PATTERNS = [
"crates/**/*.rs",
"apps/**/*.rs",
"scripts/*.sh",
"tests/**/*.sh",
"tests/**/*.py",
"packaging/**/*.sh",
"packaging/**/*.nsi",
"packaging/**/*.py",
"packaging/**/*.swift",
"tests/engines/*.c",
"crates/**/*.c",
"crates/**/*.h",
"patches/**/*.patch",
"tests/**/*.swift",
"apps/*/web/src/**/*.js",
]
LINE_COMMENT = {
".rs": re.compile(r"^\s*//[!/]?\s?(.*)$"),
".c": re.compile(r"^\s*(?://[!/]?\s?(.*)|/\*\s?(.*?)\s*\*/)$"),
".h": re.compile(r"^\s*(?://[!/]?\s?(.*)|/\*\s?(.*?)\s*\*/)$"),
".nsi": re.compile(r"^\s*;\s?(.*)$"),
".swift": re.compile(r"^\s*//\s?(.*)$"),
".js": re.compile(r"^\s*(?://\s?(.*)|/\*\*?\s?(.*?)\s*\*/)$"),
}
def comment_re(path):
if path.suffix == ".patch":
return re.compile(r"^\+\s*(?://[!/]?\s?(.*)|/\*\s?(.*?)\s*\*/)$")
return LINE_COMMENT.get(path.suffix, re.compile(r"^\s*#\s?(.*)$"))
too_long = []
repeated = {}
for pattern in PATTERNS:
for path in sorted(root.glob(pattern)):
comment = comment_re(path)
previous = None
lines = path.read_text().split("\n")
if path.suffix == ".py":
start = 1 if lines and lines[0].startswith("#!") else 0
if start < len(lines) and lines[start].startswith('"""'):
doc = []
for line in lines[start:]:
doc.append(line.strip('" '))
if line.count('"""') >= 2 or (len(doc) > 1 and '"""' in line):
break
lines = lines[:start] + [f"# {text}" for text in doc] + lines[start + len(doc):]
for number, line in enumerate(lines, 1):
if line.startswith("#!"):
continue
# A block comment left open spans lines.
if path.suffix in (".c", ".h", ".js") and re.search(r"/\*(?!.*\*/)", line):
repeated.setdefault(path, number)
match = comment.match(line)
said = match and next(group for group in match.groups() if group is not None)
if match and len(said.split()) > 10:
too_long.append(f"{path.relative_to(root)}:{number}: {said[:60]}")
if match and previous == number - 1:
repeated.setdefault(path, number)
previous = number if match else None
problems = too_long + [
f"{path.relative_to(root)}:{number}: comment runs past one line"
for path, number in sorted(repeated.items())
]
if problems:
sys.exit("FAIL: comments break the one-line ten-word rule:\n" + "\n".join(problems))
print("comments: every comment is one line and ten words or fewer")
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Each desktop entry is valid and matches its window class.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v desktop-file-validate >/dev/null || {
echo "SKIP desktop-files: desktop-file-validate is not installed"
exit 0
}
count=0
while IFS=$'\t' read -r slug name _; do
file=$work/$slug.desktop
"$root/packaging/linux/appimage.sh" --desktop "$slug" >"$file"
report=$(desktop-file-validate "$file" 2>&1) || fail "$slug.desktop is not valid: $report"
[ -z "$report" ] || fail "$slug.desktop has hints: $report"
grep -qx "Name=$name" "$file" || fail "$slug.desktop has the wrong name"
grep -qx "StartupWMClass=$slug" "$file" || fail "$slug.desktop does not match the window app id"
grep -qx "Exec=$slug %F" "$file" || fail "$slug.desktop starts the wrong binary"
count=$((count + 1))
done <"$root/packaging/apps.tsv"
[ "$count" -eq 8 ] || fail "checked $count desktop files, expected 8"
echo "desktop-files: 8 valid"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# ElyDoc's spacing fields: quick edits keep each other.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
trap 'kill "${pid:-0}" 2>/dev/null || true; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift"
swiftc -O -o "$work/click" "$root/tests/scripts/click.swift"
[ "$("$work/axv" $$)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
ELY_SMOKE=hold ELY_LOCALE=en HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/ely-doc" >"$work/out" 2>"$work/err" &
pid=$!
log() { find "$work" -path '*/ElyOffice/ely-doc/ely.log' 2>/dev/null | head -n 1; }
for _ in $(seq 200); do grep -q "doc: opened" "$(log)" 2>/dev/null && break; sleep 0.2; done
grep -q "doc: opened Untitled 1" "$(log)" || fail "no blank tab: $(tail -n 3 "$work/err")"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 0.5
keys 'keystroke "Spacing"'
# Number fields top to bottom: above, below, tab position.
fields() { "$work/axv" "$pid" | awk -F'\t' '$1 == "AXTextField" && $2 ~ /^value=[0-9.]+$/ { sub("value=", "", $2); sub("at=", "", $5); split($5, p, ","); print p[2], p[1], $2 }' | sort -n; }
spots=
for _ in 1 2 3 4 5; do
spots=$(fields)
[ "$(wc -l <<<"$spots")" -ge 3 ] && break
sleep 1
done
[ "$(wc -l <<<"$spots")" -ge 3 ] || fail "the spacing fields are not in the AX tree"
read -r above_y above_x _ <<<"$(sed -n 1p <<<"$spots")"
read -r below_y below_x _ <<<"$(sed -n 2p <<<"$spots")"
type_into() {
"$work/click" "$1" "$2"
sleep 0.3
keys 'keystroke "a" using command down'
keys "keystroke \"$3\""
keys 'key code 36'
}
type_into "$above_x" "$above_y" 6
type_into "$below_x" "$below_y" 12
"$work/click" $((above_x - 300)) "$above_y"
want=$'6.0\n12.0'
seen=
for _ in 1 2 3 4 5 6; do
sleep 1
seen=$(fields | head -n 2 | awk '{ print $3 }')
[ "$seen" = "$want" ] && break
done
[ "$seen" = "$want" ] || fail "spacing reads $(tr '\n' ' ' <<<"$seen")after 6 and 12"
grep -c "panel runs paragraph.spacing" "$(log)" | grep -qx 2 || fail "expected two spacing commands"
echo "ok: doc-props"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# ElyDoc opens a blank tab; Cmd+N works at once.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
trap 'kill "${pid:-0}" 2>/dev/null || true; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift"
[ "$("$work/axv" $$)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
ELY_SMOKE=hold ELY_LOCALE=en HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/ely-doc" >"$work/out" 2>"$work/err" &
pid=$!
log() { find "$work" -path '*/ElyOffice/ely-doc/ely.log' 2>/dev/null | head -n 1; }
for _ in $(seq 200); do grep -q "doc: opened" "$(log)" 2>/dev/null && break; sleep 0.2; done
grep -q "doc: opened Untitled 1" "$(log)" || fail "no blank tab: $(tail -n 3 "$work/err")"
tree() { "$work/axv" "$pid"; }
seen=
for _ in 1 2 3 4 5; do
seen=$(tree)
grep -q "Untitled 1" <<<"$seen" && break
sleep 1
done
grep -q "Untitled 1" <<<"$seen" || fail "the tab is not in the AX tree"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" \
-e 'delay 0.5' -e 'tell application "System Events" to keystroke "n" using command down' >/dev/null
for _ in 1 2 3 4 5; do
seen=$(tree)
grep -q "New Document" <<<"$seen" && break
sleep 1
done
grep -q "New Document" <<<"$seen" || fail "Cmd+N at startup opened no New dialog"
echo "ok: doc-shell"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Runs doctor.sh against stub tools only.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
stubs=$(mktemp -d)
trap 'rm -rf "$stubs"' EXIT
tools="rustc cargo node pnpm cmake ninja pkg-config git git-lfs clang python3"
fail() {
echo "FAIL: $*" >&2
exit 1
}
run() { PATH="$stubs" "$BASH" "$root/scripts/doctor.sh"; }
for tool in $tools; do
printf '#!/bin/sh\necho "%s 1.0"\n' "$tool" >"$stubs/$tool"
chmod +x "$stubs/$tool"
done
out=$(run) || fail "all tools present: doctor exited non-zero"
for tool in $tools; do
grep -q "^$tool *$tool 1.0$" <<<"$out" || fail "no version line for $tool: $out"
done
for tool in $tools; do
mv "$stubs/$tool" "$stubs/$tool.off"
if out=$(run); then fail "$tool absent: doctor exited 0"; fi
grep -q "^MISSING $tool$" <<<"$out" || fail "no MISSING line for $tool: $out"
mv "$stubs/$tool.off" "$stubs/$tool"
done
printf '#!/bin/sh\necho "dyld: Library not loaded" >&2\nexit 134\n' >"$stubs/ninja"
if out=$(run); then fail "ninja broken: doctor exited 0"; fi
grep -q '^BROKEN ninja: dyld: Library not loaded$' <<<"$out" || fail "no BROKEN line for ninja: $out"
echo "doctor.sh: present, absent x11, broken pass"
+16
View File
@@ -0,0 +1,16 @@
// Drags the left button from x1 y1 to x2 y2.
import CoreGraphics
import Foundation
let a = CommandLine.arguments.dropFirst().map { Double($0)! }
let (from, to) = (CGPoint(x: a[0], y: a[1]), CGPoint(x: a[2], y: a[3]))
func post(_ kind: CGEventType, _ at: CGPoint) {
CGEvent(mouseEventSource: nil, mouseType: kind, mouseCursorPosition: at, mouseButton: .left)!.post(tap: .cghidEventTap)
usleep(30_000)
}
post(.mouseMoved, from)
post(.leftMouseDown, from)
for step in 1...20 {
let t = Double(step) / 20
post(.leftMouseDragged, CGPoint(x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t))
}
post(.leftMouseUp, to)
+59
View File
@@ -0,0 +1,59 @@
// Drags a file from its window onto x, y.
import AppKit
let args = CommandLine.arguments
let file = URL(fileURLWithPath: args[1])
let target = CGPoint(x: Double(args[2])!, y: Double(args[3])!)
let start = CGPoint(x: 60, y: 60)
final class Source: NSView, NSDraggingSource {
func draggingSession(_: NSDraggingSession, sourceOperationMaskFor _: NSDraggingContext) -> NSDragOperation {
.copy
}
func draggingSession(_: NSDraggingSession, endedAt _: NSPoint, operation: NSDragOperation) {
print(operation.isEmpty ? "refused" : "dropped")
exit(0)
}
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
let item = NSDraggingItem(pasteboardWriter: file as NSURL)
item.setDraggingFrame(bounds, contents: NSImage(size: bounds.size))
beginDraggingSession(with: [item], event: event, source: self)
}
}
func post(_ type: CGEventType, _ at: CGPoint) {
CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: at, mouseButton: .left)?
.post(tap: .cghidEventTap)
usleep(40000)
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let height = NSScreen.screens[0].frame.height
let frame = NSRect(x: start.x - 20, y: height - start.y - 20, width: 40, height: 40)
let window = NSWindow(contentRect: frame, styleMask: .borderless, backing: .buffered, defer: false)
window.contentView = Source(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
window.level = .floating
window.orderFrontRegardless()
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
post(.mouseMoved, start)
post(.leftMouseDown, start)
for step in 1...30 {
let t = Double(step) / 30
post(.leftMouseDragged, CGPoint(x: start.x + (target.x - start.x) * t, y: start.y + (target.y - start.y) * t))
}
usleep(300000)
post(.leftMouseDragged, target)
post(.leftMouseUp, target)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
print("timeout")
exit(1)
}
app.run()
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# ElyMark signed with hardened runtime still draws its page.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || {
echo "SKIP hardened-macos: macOS only"
exit 0
}
[ -n "${ELY_SIGN_IDENTITY:-}" ] || {
echo "SKIP hardened-macos: set ELY_SIGN_IDENTITY to a signing identity"
exit 0
}
work=$(cd "$(mktemp -d)" && pwd -P)
trap 'pkill -f "$work/ElyMark.app/Contents/MacOS/" 2>/dev/null || true; sleep 1; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
ELY_DIST=$work "$root/packaging/macos/bundle.sh" ely-mark debug >"$work/bundle.log" 2>&1 ||
fail "bundle: $(tail -n 3 "$work/bundle.log")"
app=$work/ElyMark.app
codesign --verify --deep --strict "$app" || fail "the signature does not verify"
codesign -d --entitlements - "$app/Contents/Frameworks/ely-mark Helper (Renderer).app" 2>/dev/null |
grep -q allow-jit || fail "the renderer helper has no JIT entitlement"
printf '# Hardened\n' >"$work/t.md"
open -n -a "$app" --env "HOME=$work/home" --env ELY_LOG=info --env ELY_NO_DIALOG=1 "$work/t.md"
log="$work/home/Library/Application Support/ElyOffice/ely-mark/ely.log"
for _ in $(seq 240); do
grep -q 'mark: shown as' "$log" 2>/dev/null && {
echo "hardened-macos: the signed page draws"
exit 0
}
sleep 0.25
done
fail "the page never showed: $(grep -E 'web:|mark:' "$log" 2>/dev/null | tail -n 5)"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Each app: macOS squircle, shapeless Linux and Windows icons.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
icons=$root/assets/icons
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ "$(uname)" = Darwin ] || {
echo "SKIP icons: macOS only (AppKit renders the masters)"
exit 0
}
python3 -c 'import PIL' 2>/dev/null || {
echo "SKIP icons: no Pillow"
exit 0
}
swiftc -O -o "$work/icon" "$root/packaging/icons/icon.swift"
# The master build.sh uses at `px`; small cut below 48.
master() { # slug, variant, px
local small=$icons/$1-small$2.svg
if [ "$3" -lt 48 ] && [ -f "$small" ]; then echo "$small"; else echo "$icons/$1$2.svg"; fi
}
count=0
while IFS=$'\t' read -r slug _; do
for file in "$slug.svg" "$slug-macos.svg" "$slug.icns" "$slug.png" "$slug.ico"; do
[ -s "$icons/$file" ] || fail "no $icons/$file; run packaging/icons/build.sh"
done
for made in icns png ico; do
for source in "$icons/$slug".svg "$icons/$slug"-*.svg; do
[ "$source" -nt "$icons/$slug.$made" ] && fail "$slug.$made is older than $(basename "$source"); run packaging/icons/build.sh"
done
done
# Every shipped size, rendered afresh from its master.
mkdir "$work/$slug"
for px in 16 24 32 48 64 128 256 512 1024; do
"$work/icon" "$px" "$(master "$slug" "" "$px")" "$work/$slug/plain-$px.png"
"$work/icon" "$px" "$(master "$slug" -macos "$px")" "$work/$slug/macos-$px.png"
done
iconutil -c iconset -o "$work/$slug.iconset" "$icons/$slug.icns"
python3 - "$icons" "$slug" "$work" <<'EOF' || fail "$slug: the rendered icons are wrong"
import os, sys
from PIL import Image
icons, slug, work = sys.argv[1:]
def raw(image):
# Premultiplied: colour under zero alpha is not drawn.
data = image.convert("RGBA").tobytes()
out = bytearray(data)
for i in range(0, len(data), 4):
for c in range(3):
out[i + c] = data[i + c] * data[i + 3] // 255
return out
def same(made, px, variant, what, most=1.0):
want = raw(Image.open(f"{work}/{slug}/{variant}-{px}.png"))
got = raw(made)
assert len(got) == len(want), f"{what}: {made.size}, not {px}"
off = sum(abs(a - b) for a, b in zip(got, want)) / len(got)
assert off < most, f"{what} is not its master (off {off:.2f})"
# Shapeless: the mark alone, no container behind it.
mark = Image.open(f"{icons}/{slug}.png")
same(mark, 512, "plain", "png")
assert mark.convert("RGBA").getpixel((0, 0))[3] == 0, "a container behind the mark"
ico = Image.open(f"{icons}/{slug}.ico")
assert sorted(ico.info["sizes"]) == [(s, s) for s in (16, 24, 32, 48, 64, 128, 256)], ico.info["sizes"]
for size in ico.info["sizes"]:
layer = ico.ico.getimage(size)
same(layer, size[0], "plain", f"ico {size[0]}")
corners = [(0, 0), (size[0] - 1, 0), (0, size[0] - 1), (size[0] - 1, size[0] - 1)]
assert all(layer.convert("RGBA").getpixel(at)[3] == 0 for at in corners), f"ico {size[0]}: a container"
names = sorted(os.listdir(f"{work}/{slug}.iconset"))
assert len(names) == 10, names
for name in names:
made = Image.open(f"{work}/{slug}.iconset/{name}")
# macOS stores 16 and 32 px lossily.
same(made, made.width, "macos", f"icns {name}", 8.0 if name in ("icon_16x16.png", "icon_32x32.png") else 1.0)
# Squircle: Apple's margin is clear, the body's middle is solid.
big = Image.open(f"{work}/{slug}.iconset/icon_512x512@2x.png").convert("RGBA")
assert big.getpixel((40, 512))[3] == 0 and big.getpixel((150, 150))[3] == 0, "not a squircle"
assert big.getpixel((512, 110))[3] == 255, "no body"
EOF
count=$((count + 1))
done <"$root/packaging/apps.tsv"
[ "$count" -eq 8 ] || fail "checked $count apps, expected 8"
echo "icons: 8 apps, squircle on macOS, shapeless on Linux and Windows"
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Counts bright pixels in a PNG region."""
import struct
import sys
import zlib
def read_png(path):
with open(path, "rb") as png:
data = png.read()
if data[:8] != b"\x89PNG\r\n\x1a\n":
sys.exit(f"FAIL: {path} is not a PNG")
pos, width, height, color, interlace, idat = 8, 0, 0, 0, 0, b""
while pos < len(data):
length, kind = struct.unpack(">I4s", data[pos : pos + 8])
body = data[pos + 8 : pos + 8 + length]
if kind == b"IHDR":
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", body)
if depth != 8 or color not in (2, 6) or interlace != 0:
sys.exit(f"FAIL: {path} needs 8-bit RGB/RGBA, not depth {depth} color {color} interlace {interlace}")
elif kind == b"IDAT":
idat += body
elif kind == b"IEND":
break
pos += 12 + length
return width, height, 4 if color == 6 else 3, zlib.decompress(idat)
def unfilter(width, height, channels, raw):
stride = width * channels
out, previous = bytearray(), bytearray(stride)
pos = 0
for _ in range(height):
kind = raw[pos]
pos += 1
line = bytearray(raw[pos : pos + stride])
pos += stride
for i in range(stride):
left = line[i - channels] if i >= channels else 0
up = previous[i]
up_left = previous[i - channels] if i >= channels else 0
if kind == 1:
line[i] = (line[i] + left) & 0xFF
elif kind == 2:
line[i] = (line[i] + up) & 0xFF
elif kind == 3:
line[i] = (line[i] + (left + up) // 2) & 0xFF
elif kind == 4:
p = left + up - up_left
pa, pb, pc = abs(p - left), abs(p - up), abs(p - up_left)
line[i] = (line[i] + (left if pa <= pb and pa <= pc else up if pb <= pc else up_left)) & 0xFF
out += line
previous = line
return out
def region_pixels(path, x0, y0, x1, y1):
width, height, channels, raw = read_png(path)
pixels = unfilter(width, height, channels, raw)
xs, xe = int(width * x0), int(width * x1)
ys, ye = int(height * y0), int(height * y1)
out = bytearray()
for y in range(ys, ye):
for x in range(xs, xe):
at = (y * width + x) * channels
out += pixels[at : at + 3]
return xe - xs, ye - ys, bytes(out)
def region_at(path, xs, ys, width, height):
shot_w, shot_h, channels, raw = read_png(path)
pixels = unfilter(shot_w, shot_h, channels, raw)
out = bytearray()
for y in range(ys, ys + height):
for x in range(xs, xs + width):
at = (y * shot_w + x) * channels
out += pixels[at : at + 3]
return bytes(out)
def main():
if sys.argv[1] == "dist":
print(bin(int(sys.argv[2], 16) ^ int(sys.argv[3], 16)).count("1"))
return
if sys.argv[1] == "dump":
shot, out, x0, y0, x1, y1 = sys.argv[2], sys.argv[3], *map(float, sys.argv[4:8])
width, height, rgb = region_pixels(shot, x0, y0, x1, y1)
head = struct.pack("<4sII4f", b"RGBZ", width, height, x0, y0, x1, y1)
with open(out, "wb") as fixture:
fixture.write(head + zlib.compress(rgb, 9))
return
if sys.argv[1] == "cmp":
shot, fixture_path = sys.argv[2], sys.argv[3]
with open(fixture_path, "rb") as fixture:
blob = fixture.read()
magic, width, height, x0, y0, x1, y1 = struct.unpack("<4sII4f", blob[:28])
if magic != b"RGBZ":
sys.exit(f"FAIL: {fixture_path} is not an RGBZ fixture")
shot_w, shot_h, _, _ = read_png(shot)
mine = region_at(shot, int(shot_w * x0), int(shot_h * y0), width, height)
theirs = zlib.decompress(blob[28:])
if len(mine) != len(theirs):
sys.exit(f"FAIL: {fixture_path} region is {len(theirs)} bytes, the shot's is {len(mine)}")
print(sum(a != b for a, b in zip(mine, theirs, strict=True)))
return
if sys.argv[1] == "hash":
path, x0, y0, x1, y1 = sys.argv[2], *map(float, sys.argv[3:7])
width, height, channels, raw = read_png(path)
pixels = unfilter(width, height, channels, raw)
cells = []
for gy in range(16):
for gx in range(16):
total = count = 0
for y in range(int(height * (y0 + (y1 - y0) * gy / 16)), int(height * (y0 + (y1 - y0) * (gy + 1) / 16))):
for x in range(int(width * (x0 + (x1 - x0) * gx / 16)), int(width * (x0 + (x1 - x0) * (gx + 1) / 16))):
at = (y * width + x) * channels
total += (pixels[at] + pixels[at + 1] + pixels[at + 2]) / 3
count += 1
cells.append(total / max(count, 1))
mean = sum(cells) / 256
bits = 0
for cell in cells:
bits = (bits << 1) | (cell >= mean)
print(f"{bits:064x}")
return
path, x0, y0, x1, y1 = sys.argv[1], *map(float, sys.argv[2:6])
width, height, channels, raw = read_png(path)
pixels = unfilter(width, height, channels, raw)
ink = 0
for y in range(int(height * y0), int(height * y1)):
for x in range(int(width * x0), int(width * x1)):
at = (y * width + x) * channels
if pixels[at] + pixels[at + 1] + pixels[at + 2] > 380:
ink += 1
print(ink)
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# The Windows installer builds, keeps data, links the runtime statically.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
nsi=$root/packaging/windows/installer.nsi
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
command -v makensis >/dev/null || {
echo "SKIP installer-windows: makensis is not installed"
exit 0
}
printf 'MZ' >"$work/app.exe"
count=0
while IFS=$'\t' read -r slug name _; do
out=$work/$slug-setup.exe
makensis -V1 -DAPP_NAME="$name" -DAPP_SLUG="$slug" -DVERSION=0.1.0 -DEXE="$work/app.exe" \
-DICON="$root/assets/icons/$slug.ico" \
-DOUTFILE="$out" "$nsi" >"$work/makensis.log" 2>&1 || fail "$slug: $(tail -n 5 "$work/makensis.log")"
[ "$(head -c 2 "$out")" = MZ ] || fail "$slug installer is not a Windows executable"
count=$((count + 1))
done <"$root/packaging/apps.tsv"
[ "$count" -eq 8 ] || fail "built $count installers, expected 8"
[ "$(grep -c 'RMDir /r' "$nsi")" -eq 1 ] || fail "the uninstaller removes more than one tree"
grep -qx ' RMDir /r "$LOCALAPPDATA\\ElyOffice\\${APP_SLUG}"' "$nsi" || fail "the removed tree is not the app's own data directory"
grep -qx ' MessageBox MB_YESNO|MB_DEFBUTTON2 "$(DeleteData)" IDNO done' "$nsi" || fail "data removal is not confirmed with No as the default"
if rustup target add x86_64-pc-windows-msvc >/dev/null 2>&1; then
mkdir -p "$work/probe/src"
printf '[package]\nname = "probe"\nversion = "0.0.0"\nedition = "2021"\n' >"$work/probe/Cargo.toml"
printf 'fn main() {}\n' >"$work/probe/src/main.rs"
cfg=$(cd "$root" && cargo rustc -q --manifest-path "$work/probe/Cargo.toml" --target x86_64-pc-windows-msvc -- --print cfg 2>&1) ||
fail "cargo rustc for the msvc target failed: $cfg"
grep -qx 'target_feature="crt-static"' <<<"$cfg" || fail "the msvc build links the C runtime dynamically"
echo "installer-windows: msvc target links the C runtime statically"
else
echo "SKIP installer-windows: msvc target is not installable, so the runtime link was not checked"
fi
if makensis -V1 -DAPP_SLUG=x -DVERSION=1 -DEXE="$work/app.exe" "$nsi" >/dev/null 2>&1; then
fail "an installer without APP_NAME built"
fi
echo "installer-windows: 8 installers build; uninstall keeps documents and asks before data"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Typing lag in a real window: input and tile times.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
app=${1:-ely-doc}
work=$(mktemp -d)
trap 'kill "${pid:-0}" 2>/dev/null || true; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p "$app" --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift"
[ "$("$work/axv" $$)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
ELY_SMOKE=hold ELY_LOG=debug ELY_LOCALE=en HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/$app" >"$work/out" 2>"$work/err" &
pid=$!
log() { find "$work" -name ely.log -path "*$app*" 2>/dev/null | head -n 1; }
for _ in $(seq 200); do grep -q "opened Untitled" "$(log)" 2>/dev/null && break; sleep 0.2; done
grep -q "opened Untitled" "$(log)" || fail "no blank tab: $(tail -n 3 "$work/err")"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
start=$(grep -c "lok: input" "$(log)" || true)
# A burst, then keys at a typist's pace.
osascript -e 'tell application "System Events" to keystroke "The quick brown fox jumps over the lazy dog. "' >/dev/null
osascript -e 'tell application "System Events"' \
-e 'repeat with c in characters of "The quick brown fox jumps over the lazy dog."' \
-e 'keystroke c' -e 'delay 0.08' -e 'end repeat' -e 'end tell' >/dev/null
sleep 2
# Percentiles of the timed lines after the start.
stats() {
tail -n +"$2" "$(log)" | grep -E "$1" | sed -E 's/.* ([0-9]+) ms$/\1/' | sort -n |
awk '{ v[NR] = $1 } END { if (NR == 0) { print "none"; exit } printf "%d samples, p50 %d, p95 %d, max %d ms\n", NR, v[int(NR / 2) + 1], v[int(NR * 0.95) + 1 > NR ? NR : int(NR * 0.95) + 1], v[NR] }'
}
line=$(grep -n "lok: input" "$(log)" | sed -n "$((start + 1))p" | cut -d: -f1)
[ -n "$line" ] || { [ -z "${ELY_LAG_LOG:-}" ] || cp "$(log)" "$ELY_LAG_LOG"; fail "no typed input reached the view"; }
echo "$app inputs: $(stats 'lok: input [0-9]+ waited' "$line")"
echo "$app tiles: $(stats 'lok: tile .* in [0-9]+ ms' "$line")"
echo "$app edit to tile: $(stats 'lok: edit to tile' "$line")"
[ -z "${ELY_LAG_LOG:-}" ] || { cp "$(log)" "$ELY_LAG_LOG"; cp "$work/err" "$ELY_LAG_LOG.err"; }
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Six apps at three widths: nothing passes the window's sides.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
shots=${ELY_LAYOUT_SHOTS:-$work/shots}
cleanup() {
local status=$?
[ -n "${pid:-}" ] && { pkill -9 -P "$pid" 2>/dev/null || true; kill "$pid" 2>/dev/null || true; }
[ "$status" = 0 ] && [ -z "${ELY_LAYOUT_SHOTS:-}" ] && rm -rf "$work" || echo "kept $work" >&2
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q --release --manifest-path "$root/Cargo.toml" --bins -p ely-lok -p ely-pdf-engine \
-p ely-doc -p ely-table -p ely-show -p ely-pdf -p ely-tree -p ely-mark
swiftc -O -o "$work/ax-frames" "$root/tests/scripts/ax-frames.swift"
[ "$("$work/ax-frames" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
mkdir -p "$shots" "$work/repo"
git -C "$work/repo" init -q
printf '# Notes\n\nA paragraph long enough to wrap at a narrow width, with **bold** words.\n' >"$work/notes.md"
cp "$work/notes.md" "$work/repo/notes.md"
fixtures=$root/tests/fixtures/office
# Elements past the window's sides; scrolled rows may pass below.
outside() {
python3 - "$1" <<'PY'
import json, sys
rows = [json.loads(line) for line in open(sys.argv[1]) if line.startswith("{")]
window = next(row for row in rows if row["role"] == "AXWindow")
wx, wy, ww, wh = window["frame"]
for row in rows:
if "frame" not in row or row["role"] in ("AXWindow", "AXMenuBar", "AXMenuBarItem", "AXMenu", "AXMenuItem"):
continue
x, y, w, h = row["frame"]
if w <= 0 or h <= 0:
continue
if x < wx - 1 or x + w > wx + ww + 1:
print(f'{row["role"]} "{row["name"]}" at {x:.0f},{y:.0f} {w:.0f}x{h:.0f}; window {wx:.0f},{wy:.0f} {ww:.0f}x{wh:.0f}')
PY
}
window_id() {
swift -e 'import CoreGraphics; let l = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as! [[String: Any]]; for w in l where (w["kCGWindowOwnerPID"] as? Int) == Int(CommandLine.arguments[1])! && (w["kCGWindowLayer"] as? Int) == 0 { print(w["kCGWindowNumber"]!); break }' "$1"
}
check() { # slug, file
local slug=$1 file=$2 home=$work/home-$1
mkdir -p "$home"
HOME=$home ELY_SMOKE=hold ELY_NO_DIALOG=1 ELY_LOCALE=en "$root/target/release/$slug" "$file" >"$work/$slug.out" 2>&1 &
pid=$!
sleep 8
for width in 640 800 1100; do
osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid)" \
-e "set size of window 1 to {$width, 680}" -e "set position of window 1 to {80, 60}" \
-e "set frontmost to true" -e "end tell" >/dev/null
sleep 2
# GPUI builds its AX tree once asked.
"$work/ax-frames" "$pid" >/dev/null
sleep 1
"$work/ax-frames" "$pid" >"$work/$slug-$width.jsonl"
[ "$(grep -c '"frame"' "$work/$slug-$width.jsonl")" -gt 8 ] || fail "$slug at $width: no AX content"
screencapture -x -l"$(window_id "$pid")" "$shots/$slug-$width.png"
out=$(outside "$work/$slug-$width.jsonl")
[ -z "$out" ] || fail "$slug at $width leaves the window:
$out"
done
pkill -9 -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
pid=
}
check ely-doc "$fixtures/t.docx"
check ely-table "$fixtures/t.xlsx"
check ely-show "$fixtures/t.pptx"
check ely-pdf "$fixtures/t.pdf"
check ely-tree "$work/repo"
check ely-mark "$work/notes.md"
echo "ok: layout (screenshots in $shots)"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# The series applies from a prefix, then as a no-op.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
src=$root/engines/lo-src
[ -d "$src/.git" ] || { echo "SKIP: no engines/lo-src"; exit 0; }
patches=("$root"/patches/libreoffice/*.patch)
files=$(sed -n 's|^+++ b/||p' "${patches[@]}" | sort -u)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
# Just the touched files at the pinned commit.
tree=$work/tree
mkdir -p "$tree"
git -C "$tree" init -q
for f in $files; do
if git -C "$src" cat-file -e "HEAD:$f" 2>/dev/null; then
mkdir -p "$tree/$(dirname "$f")"
git -C "$src" show "HEAD:$f" >"$tree/$f"
fi
done
git -C "$tree" add -A
git -C "$tree" -c user.name=t -c user.email=t@t commit -qm pin
half=$((${#patches[@]} / 2))
for ((i = 0; i < half; i++)); do
git -C "$tree" apply "${patches[$i]}"
done
out=$("$root/scripts/apply-lo-patches.sh" "$tree")
grep -q "^apply-lo-patches.sh: $half of ${#patches[@]} patches already" <<<"$out" || {
echo "FAIL: the prefix was not found: $out"
exit 1
}
out=$("$root/scripts/apply-lo-patches.sh" "$tree")
grep -q "${#patches[@]} of ${#patches[@]} patches already" <<<"$out" || {
echo "FAIL: a second run was not a no-op: $out"
exit 1
}
for f in $files; do
[ ! -e "$tree/$f" ] || cmp -s "$tree/$f" "$src/$f" || {
echo "FAIL: $f differs from engines/lo-src"
exit 1
}
done
echo "lo-patches: the series applies from patch $half and again as a no-op"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# A live LokView takes IME, keys, mouse and paste.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
lok=${ELY_LOK_DIR:-$root/engines/lo-src/instdir/LibreOffice.app/Contents/Frameworks}
[ -d "$lok" ] || { echo "SKIP: no LOK build at $lok"; exit 0; }
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins --manifest-path "$root/Cargo.toml"
cargo build -q -p ely-lok --features view --example lokview --manifest-path "$root/Cargo.toml"
out=$(ELY_SMOKE=hold ELY_LOK_DIR="$lok" HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/examples/lokview" 2>"$work/err") ||
fail "the probe did not exit cleanly: $(tail -n 5 "$work/err")"
grep -q "probe: focused=true" <<<"$out" || fail "the clicks did not keep focus: $out"
if [ -n "${ELY_CLIPBOARD_TEST:-}" ]; then
grep -q 'probe: copied=Some(Html' <<<"$out" || fail "the copy left no html: $out"
want="probe: paragraphs=你好Hello 世界X|Z|你好Hello 世界X|Z|"
else
want="probe: paragraphs=你好Hello 世界X|Z"
fi
grep -qxF "$want" <<<"$out" || fail "expected '$want', got: $out"
! grep -q "tile paint failed\|input failed\|panicked" "$work/err" || fail "errors: $(grep -m3 'failed\|panicked' "$work/err")"
swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift"
if [ "$("$work/axv" $$)" = untrusted ]; then
echo "SKIP: a11y bridge check needs Accessibility permission"
else
ax_check() {
ELY_PROBE_AX=$1 ELY_SMOKE=hold ELY_LOK_DIR="$lok" HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/examples/lokview" >"$work/ax.out" 2>"$work/ax.err" &
probe=$!
for _ in $(seq 120); do grep -q "probe: ax hold" "$work/ax.out" 2>/dev/null && break; sleep 0.5; done
grep -q "probe: ax hold" "$work/ax.out" || fail "the $1 AX probe never held: $(tail -n 3 "$work/ax.err")"
seen=
for _ in 1 2 3 4 5; do
seen=$("$work/axv" "$probe")
grep -qF "AXTextArea value=$2 " <<<"$seen" && break
sleep 1
done
wait "$probe" || fail "the $1 AX probe did not exit cleanly"
grep -qF "AXTextArea value=$2 " <<<"$seen" || fail "the $1 AX tree lacks '$2': $seen"
}
ax_check initial "Hello 世界"
ax_check edited Z
fi
echo "ok: lokview"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# ElyMark's built page renders each package offline, live.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
work=$(mktemp -d)
probe=
trap 'if [ -n "$probe" ]; then kill "$probe" 2>/dev/null || true; fi; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
content=$root/engines/content/ely-mark
for name in @milkdown/kit @codemirror/view katex mermaid; do
grep -q "^$name " "$content/THIRD-PARTY-NOTICES.txt" || fail "no notice for $name"
done
cargo build -q -p ely-mark --example markprobe --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift"
ELY_LOG=info ELY_LOCALE=en ELY_SMOKE=hold HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/examples/markprobe" >"$work/out" 2>"$work/err" &
probe=$!
for _ in $(seq 240); do
grep -q '"step":"visual"' "$work/out" && break
kill -0 "$probe" 2>/dev/null || break
sleep 0.25
done
grep -q '"step":"visual"' "$work/out" || fail "no visual report: $(tail -n 5 "$work/err")"
# Less than all background: the page is drawn.
bg=$(grep '"step":"visual"' "$work/out" | grep -oE '"bg":"rgba\([0-9]+, [0-9]+, [0-9]+' | grep -oE '[0-9]+, [0-9]+, [0-9]+$' | tr -d ' ')
share=$("$work/share" "$probe" "$bg")
case $share in
denied) fail "needs Screen Recording permission" ;;
none) fail "the probe has no window" ;;
esac
awk -v s="$share" 'BEGIN { exit !(s < 0.97) }' || fail "the page is not on screen (background share $share)"
wait "$probe" || fail "the probe failed: $(tail -n 5 "$work/err")"
probe=
grep -qxF "mark: done" "$work/out" || fail "unfinished: $(cat "$work/out")"
# The earlier of two quick shows never reports.
shows=$(grep '^mark: shown' "$work/out" | tr -d '"' | cut -d' ' -f3 | tr '\n' ' ')
[ "$shows" = "visual preview source source visual visual preview source source visual preview source visual source source visual preview source source source visual visual visual " ] || fail "shows reported: $shows"
python3 - "$work/out" <<'PY'
import json, sys
seen = {}
for line in open(sys.argv[1]):
if line.startswith("mark: {"):
read = json.loads(line[6:])
seen[read["step"]] = read
def want(step, key, value):
if seen[step][key] != value:
sys.exit(f"FAIL: {step} {key} is {seen[step][key]!r}, not {value!r}")
languages = ["rust", "LaTeX", "LaTeX", "mermaid", "mermaid"]
for step in ("visual", "themed", "preview"):
for key, value in [("title", "Title"), ("heading", True), ("inline", True), ("display", True), ("tasks", 2),
("cells", 2), ("notes", 1), ("font", True), ("editors", 1)]:
want(step, key, value)
blocks = seen[step]["blocks"]
if [block["language"] for block in blocks] != languages:
sys.exit(f"FAIL: {step} blocks are {blocks}")
# Preview shows a block's preview alone, when it has one.
for at, block in enumerate(blocks):
if block["editors"] != 1 or block["code"] != (step != "preview" or at == 0):
sys.exit(f"FAIL: {step} block {at} code is not shown: {block}")
width, height = blocks[3]["diagram"]
if not blocks[1]["math"] or not blocks[3]["drawn"] or width < 100 or height < 80:
sys.exit(f"FAIL: {step} math or diagram is not shown: {blocks}")
if "(9)" not in blocks[2]["failure"] or "line 3" not in blocks[4]["failure"]:
sys.exit(f"FAIL: {step} errors lack source or place: {blocks}")
editable = "true" if step != "preview" else "false"
if any(block["editable"] != editable for block in blocks):
sys.exit(f"FAIL: {step} code blocks are not {editable}: {blocks}")
fills = [seen[step]["blocks"][3]["fill"] for step in ("visual", "themed")]
if not fills[0] or fills[0] == fills[1]:
sys.exit(f"FAIL: the diagram kept its colors: {fills}")
want("visual", "editable", "true")
want("preview", "editable", "false")
want("source", "first", "# Title")
want("source", "source", True)
want("source", "blocks", [])
want("race", "mode", "source")
want("race", "first", "# NEW")
want("race", "editors", 1)
want("asked", "mode", "preview")
want("stale", "mode", "source")
want("owner", "mode", "source")
# Two broken patterns per mode error; empty ones open nothing.
if seen["find"]["panels"] != {"source": 0, "visual": 0} or seen["find"]["errors"] != 4:
sys.exit(f"FAIL: find: {seen['find']}")
# A zero-width match after an emoji: seven hits, per mode.
if seen["find"]["totals"].count(7) != 2:
sys.exit(f"FAIL: zero-width find: {seen['find']}")
look = seen["look"]
# 36rem at 120 percent of 16px: 691.2px.
if look["dim"] != look["blocks"] - 1 or look["bright"] != "1" or not look["centered"] or look["width"] != "691.2px" or not look["serif"]:
sys.exit(f"FAIL: the writing view: {look}")
if "OLD" in seen["race"]["text"]:
sys.exit("FAIL: the earlier show stayed")
# A diagram begun before a change never lands after.
language = seen["language"]["blocks"][0]
if language["language"].lower() != "rust" or language["drawn"] or language["diagram"] != [0, 0]:
sys.exit(f"FAIL: an old diagram stayed in a Rust block: {language}")
late = seen["midtheme"]["blocks"][0]
if not late["drawn"] or {late["fill"], late["first"]} != {seen["visual"]["blocks"][3]["fill"]}:
sys.exit(f"FAIL: a diagram drawn mid-change showed the old theme: {late}")
# Draws for a gone editor never run.
if seen["release"]["long"] > 1:
sys.exit(f"FAIL: {seen['release']['long']} long stalls after leaving the diagrams")
PY
! grep -q "refused" "$work/err" || fail "the page reached outside: $(grep -m3 refused "$work/err")"
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
echo "ok: mark content (background share $share)"
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
# MARK-01 to 04 live: modes, images, new and rename.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
work=$(mktemp -d)
app=
trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift"
# A magenta picture beside the document.
python3 - "$work/pic.png" <<'PY'
import struct, sys, zlib
w, h = 240, 120
rows = b"".join(b"\0" + b"\xff\x00\xaa" * w for _ in range(h))
def chunk(kind, data):
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")
open(sys.argv[1], "wb").write(png)
PY
text=$'---\ntitle: Modes\n---\n# Modes\n\nFirst line.\n\n![pic](pic.png)\n\n- [ ] task\n\nLast line.\n'
printf '%s' "$text" >"$work/doc.md"
cp "$work/doc.md" "$work/orig.md"
ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" XDG_DATA_HOME="$work/data" \
"$root/target/debug/ely-mark" "$work/doc.md" >"$work/out" 2>"$work/err" &
app=$!
shows() { grep -c "mark: shown as" "$work/err" || true; }
# Waits for show number `n`, as `mode`.
wait_shown() {
for _ in $(seq 120); do
[ "$(shows)" -ge "$1" ] && break
kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")"
sleep 0.25
done
[ "$(grep "mark: shown as" "$work/err" | sed -n "$1p" | grep -c "\"$2\"")" = 1 ] ||
fail "show $1 is not $2: $(grep 'mark: shown' "$work/err")"
}
key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; }
saved() {
local before
before=$(grep -c "office: saved" "$work/err" || true)
key 'keystroke "s" using command down'
for _ in $(seq 40); do
[ "$(grep -c "office: saved" "$work/err" || true)" -gt "$before" ] && return 0
sleep 0.25
done
fail "no save"
}
same() { cmp -s "$work/orig.md" "$1" || fail "$2: $(cat "$1")"; }
wait_shown 1 visual
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 1' >/dev/null
share=$("$work/share" "$app" 255,0,170)
case $share in denied) fail "needs Screen Recording permission" ;; none) fail "no window" ;; esac
awk -v s="$share" 'BEGIN { exit !(s > 0.003) }' || fail "the picture beside the file did not show ($share)"
# Switching alone changes nothing.
key 'keystroke "2" using command down'
wait_shown 2 source
key 'keystroke "3" using command down'
wait_shown 3 preview
key 'keystroke "1" using command down'
wait_shown 4 visual
saved
same "$work/doc.md" "switching modes changed the file"
# Preview takes no typing.
key 'keystroke "3" using command down'
wait_shown 5 preview
key 'keystroke "z"'
key 'keystroke "1" using command down'
wait_shown 6 visual
saved
same "$work/doc.md" "preview took typing"
# Visual edits undo after a trip through preview.
key 'keystroke "y"'
key 'keystroke "3" using command down'
wait_shown 7 preview
key 'keystroke "1" using command down'
wait_shown 8 visual
key 'keystroke "z" using command down'
wait_shown 9 visual
saved
same "$work/doc.md" "undo did not survive the trip"
# A source edit shows in visual and saves.
key 'keystroke "2" using command down'
wait_shown 10 source
key 'keystroke "x"'
key 'keystroke "1" using command down'
wait_shown 11 visual
saved
[ "$(cat "$work/doc.md"; echo .)" = "x$text." ] || fail "source edit: $(cat "$work/doc.md")"
# One timeline: undo walks back through both modes, redo returns.
key 'keystroke "v"'
key 'keystroke "2" using command down'
wait_shown 12 source
key 'keystroke "s"'
key 'keystroke "1" using command down'
wait_shown 13 visual
key 'keystroke "z" using command down'
wait_shown 14 visual
key 'keystroke "z" using command down'
wait_shown 15 visual
saved
[ "$(cat "$work/doc.md"; echo .)" = "x$text." ] || fail "undo across modes: $(cat "$work/doc.md")"
key 'keystroke "z" using {command down, shift down}'
wait_shown 16 visual
saved
[ "$(cat "$work/doc.md"; echo .)" = "vx$text." ] || fail "redo across modes: $(cat "$work/doc.md")"
key 'keystroke "z" using command down'
wait_shown 17 visual
saved
# Rename refuses a taken name, moves to a free one.
printf 'other\n' >"$work/taken.md"
key 'key code 120'
key 'keystroke "a" using command down'
key 'keystroke "taken.md"'
key 'key code 36'
for _ in $(seq 20); do grep -q "did not rename" "$work/err" && break; sleep 0.25; done
grep -q "did not rename" "$work/err" || fail "a rename onto a file went through"
[ "$(cat "$work/taken.md")" = other ] || fail "the taken file changed"
key 'key code 120'
key 'keystroke "a" using command down'
key 'keystroke "moved.md"'
key 'key code 36'
for _ in $(seq 20); do grep -q "renamed .*moved.md" "$work/err" && break; sleep 0.25; done
[ -f "$work/moved.md" ] && [ ! -e "$work/doc.md" ] || fail "rename: $(ls "$work")"
recent=$(find "$work/Library" -name recent.json)
grep -q 'moved.md' "$recent" && ! grep -q 'doc.md' "$recent" || fail "recent after rename: $(cat "$recent")"
key 'keystroke "w"'
saved
[ "$(cat "$work/moved.md"; echo .)" = "wx$text." ] || fail "save after rename: $(cat "$work/moved.md")"
# Undone steps still redo after a mode trip.
key 'keystroke "2" using command down'
wait_shown 18 source
key 'keystroke "q"'
key 'keystroke "z" using command down'
key 'keystroke "3" using command down'
wait_shown 19 preview
key 'keystroke "2" using command down'
wait_shown 20 source
key 'keystroke "z" using {command down, shift down}'
wait_shown 21 source
saved
[ "$(cat "$work/moved.md"; echo .)" = "qwx$text." ] || fail "source redo after a trip: $(cat "$work/moved.md")"
key 'keystroke "1" using command down'
wait_shown 22 visual
key 'keystroke "p"'
key 'keystroke "z" using command down'
key 'keystroke "3" using command down'
wait_shown 23 preview
key 'keystroke "1" using command down'
wait_shown 24 visual
key 'keystroke "z" using {command down, shift down}'
wait_shown 25 visual
saved
[ "$(cat "$work/moved.md"; echo .)" = "pqwx$text." ] || fail "visual redo after a trip: $(cat "$work/moved.md")"
# New opens an empty visual document.
key 'keystroke "n" using command down'
wait_shown 26 visual
fresh=$(find "$work/Library" -name untitled.md)
[ -n "$fresh" ] && [ ! -s "$fresh" ] || fail "the new document is not empty: $fresh"
key 'keystroke "q" using command down'
key 'key code 36'
for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done
app=
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
echo "ok: mark modes (picture share $share)"
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env bash
# MARK-09, 11, 13, 14 live: look, output, Git, large files.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
command -v pdftotext >/dev/null || { echo "SKIP: no pdftotext"; exit 0; }
[ -x "$root/engines/git/bin/git" ] || export ELY_GIT=${ELY_GIT:-$(command -v git)}
work=$(mktemp -d)
app=
trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; pkill -f "$work" 2>/dev/null || true; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
cargo build -q -p ely-mark -p ely-tree -p ely-pdf --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press; do
swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"
done
repo=$work/repo
mkdir -p "$repo/assets"
python3 -c 'import struct,zlib,sys
c=lambda t,d: struct.pack(">I",len(d))+t+d+struct.pack(">I",zlib.crc32(t+d))
open(sys.argv[1],"wb").write(b"\x89PNG\r\n\x1a\n"+c(b"IHDR",struct.pack(">IIBBBBB",1,1,8,2,0,0,0))+c(b"IDAT",zlib.compress(b"\0\xff\0\0"))+c(b"IEND",b""))' "$repo/assets/pic.png"
cp "$repo/assets/pic.png" "$repo/assets/high.png"
doc=$repo/t.md
printf '%s\n' '# Title' '' 'Some $x^2$ math.' '' '![pic](assets/pic.png)' '' "<img src='assets/pic.png'>" '' \
"<img alt='a > b' src='assets/p&#105;c.png'>" '' '```mermaid' 'graph TD; A-->B' '```' '' \
'```mermaid' 'graph TD; SECRET_WORDS -->' '```' '' '<div>RAW TEXT</div><div>TWO</div>' '' \
"<img src='assets/pic.png' srcset='assets/high.png 1x'>" '' "<img srcset=' assets/high.png 1x, assets/pic.png 2x'>" '' \
"<picture><source srcset='assets/high.png'><img src='assets/pic.png'></picture>" '' \
'$\privateDocumentToken$' '' '![gone](missing.png)' '' 'END MARK' >"$doc"
git -C "$repo" init -q
git -C "$repo" -c user.name=t -c user.email=t@e.x add -A
git -C "$repo" -c user.name=t -c user.email=t@e.x commit -qm one
original=$(cat "$doc")
launch() {
ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \
"$root/target/debug/ely-mark" "$@" >"$work/log" 2>&1 &
app=$!
waits "mark: shown as"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null
}
waits() {
for _ in $(seq 160); do
grep -qF "$1" "$work/log" && return 0
kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/log")"
sleep 0.25
done
fail "never saw '$1': $(grep -E 'mark:|office:' "$work/log" | tail -n 5)"
}
count() { grep -c "$1" "$work/log" || true; }
front() { osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 1' >/dev/null; }
key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; }
# The window's own titles; system menus list recent files too.
seen() { "$work/ax-values" "$app" | grep -v '^AXMenu' | grep -qF "title=$1"; }
press() {
for _ in $(seq 40); do
[ "$("$work/ax-press" "$app" "$@")" = pressed ] && { sleep 0.8; return 0; }
sleep 0.25
done
fail "could not press $*"
}
quit() {
kill "$app"
wait "$app" 2>/dev/null || true
app=
}
# Asks a `kind` export beside the document, maybe named `$2`.
ask_export() {
front
key 'keystroke "e" using command down'
press "$1"
press "Export…" AXButton
# The panel opens in the document's folder, the stem chosen.
sleep 3
[ -z "${2:-}" ] || key "keystroke \"$2\""
# The panel's own button: Return alone did not always save.
press Save AXButton
}
# Exports `kind`; waits for its log line.
export_as() {
local before failed
before=$(count "mark: exported")
failed=$(count "export failed")
ask_export "$@"
for _ in $(seq 160); do
[ "$(count "mark: exported")" -gt "$before" ] && return 0
[ "$(count "export failed")" -gt "$failed" ] && fail "export: $(grep "export failed" "$work/log" | tail -n 1)"
sleep 0.25
done
fail "no $1 export: $(grep -E "mark:|office:|dialog|panel" "$work/log" | tail -n 4)"
}
launch "$doc"
[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
# Git sees the file; its item opens ElyTree there.
for _ in $(seq 40); do seen "Git: unchanged" && break; sleep 0.25; done
seen "Git: unchanged" || fail "no Git state: $(grep 'git says' "$work/log" | tail -n 1)"
key 'keystroke "f" using command down'
key 'key code 53'
key 'key code 125 using command down'
key 'keystroke "Z"'
for _ in $(seq 60); do seen "Git: changed" && break; sleep 0.25; done
seen "Git: changed" || fail "Git missed the change: $(grep -E "git says|autosave|saved" "$work/log" | tail -n 4)"
printf '%s\n' "$original" >"$doc"
press "Git: changed"
for _ in $(seq 40); do pgrep -f "target/debug/ely-tree .*${repo#/private}" >/dev/null && break; sleep 0.25; done
pgrep -f "target/debug/ely-tree .*${repo#/private}" >/dev/null || fail "ElyTree did not open the repository"
# ElyTree takes the file itself and shows its history (§16).
waits "tree: file history of t.md"
pkill -f "target/debug/ely-tree .*${repo#/private}" || true
sleep 2
front
# Focus mode folds the chrome; the look outlives the app.
press "Focus Mode"
! seen "t.md" || fail "focus mode kept the tabs"
press "Serif Font"
quit
grep -q '"focus": true' "$work/Library/Application Support/ElyOffice/ely-mark/look.json" || fail "the look was not kept"
[ "$(cat "$doc")" = "$original" ] || fail "the look changed the file: $(cat "$doc")"
printf '# Other\n' >"$repo/b.md"
launch "$repo/b.md" "$doc"
! seen "t.md" || fail "focus mode did not come back"
press "Focus Mode"
seen "t.md" || fail "focus mode stayed"
# Tabs open in any order; t.md's page takes the keys.
press t.md
key 'keystroke "f" using command down'
key 'key code 53'
# Each export holds the whole document.
export_as HTML
html=$repo/t.html
grep -q 'class="katex' "$html" && grep -q '<svg' "$html" && grep -q 'END MARK' "$html" ||
fail "the HTML lacks a part"
# Both image forms inline; what was left out is said.
[ "$(grep -o 'data:image/png;base64' "$html" | wc -l | tr -d ' ')" = 6 ] || fail "an image stayed outside"
! grep -qE 'srcset|assets/' "$html" || fail "a responsive source stayed outside"
grep -q "without 1 images and 1 diagrams; formulas failed at lines \[27\]" "$work/log" ||
fail "the lost parts went unsaid: $(grep 'mark: exported' "$work/log" | tail -n 1)"
! grep -qE 'SECRET_WORDS|privateDocumentToken|missing\.png' "$work/log" || fail "the log holds document text: $(grep -oE '.{0,80}(SECRET_WORDS|privateDocumentToken)' "$work/log" | head -n 1)"
# A name with `#` still links its folder.
export_as "HTML and Folder" "report#1"
linked=$repo/report#1.html
grep -q 'href="report%231_files/export.css"' "$linked" && grep -q 'src="report%231_files/1-pic.png"' "$linked" &&
[ -s "$repo/report#1_files/export.css" ] || fail "the folder export: $(grep -o 'href="[^"]*"' "$linked")"
! grep -qE 'srcset|assets/' "$linked" && [ -s "$repo/report#1_files/2-high.png" ] &&
[ "$(grep -o 'src="report%231_files/[^"]*png"' "$linked" | wc -l | tr -d ' ')" = 6 ] ||
fail "the folder lacks a responsive image: $(ls "$repo/report#1_files")"
# A PDF never lands on the open file via links.
ln -s b.md "$repo/other.pdf"
ask_export PDF other
for _ in $(seq 20); do seen Replace && break; sleep 0.25; done
press Replace AXButton
for _ in $(seq 80); do grep -q "is open in ElyMark" "$work/log" && break; sleep 0.25; done
grep -q "is open in ElyMark" "$work/log" || fail "the export wrote over another open tab"
[ "$(cat "$repo/b.md")" = "# Other" ] || fail "another open file was overwritten"
rm "$repo/other.pdf"
ln -s t.md "$repo/alias.pdf"
ask_export PDF alias
for _ in $(seq 20); do seen Replace && break; sleep 0.25; done
press Replace AXButton
for _ in $(seq 80); do grep -q "export failed" "$work/log" && break; sleep 0.25; done
grep -q "export failed" "$work/log" || fail "the export wrote through the link"
grep -q '^# Title' "$doc" || fail "the open file was overwritten: $(head -c 20 "$doc")"
rm "$repo/alias.pdf"
export_as PDF
[ "$(head -c 5 "$repo/t.pdf")" = "%PDF-" ] || fail "no PDF"
# The toast's Open shows the PDF in ElyPDF (§16).
press Open AXButton
for _ in $(seq 40); do pgrep -f "target/debug/ely-pdf .*t.pdf" >/dev/null && break; sleep 0.25; done
pgrep -f "target/debug/ely-pdf .*t.pdf" >/dev/null || fail "Open did not show the PDF in ElyPDF"
pkill -f "target/debug/ely-pdf .*t.pdf" || true
front
pdftotext "$repo/t.pdf" - | grep -q "END MARK" || fail "the PDF lacks text"
[ "$(grep -c 'without 1 images' "$work/log")" -ge 3 ] || fail "the PDF hid a missing image"
export_as "Plain Text"
grep -q 'x^2' "$repo/t.txt" && grep -qx 'RAW TEXT' "$repo/t.txt" && grep -qx 'TWO' "$repo/t.txt" && grep -q 'END MARK' "$repo/t.txt" || fail "the text: $(cat "$repo/t.txt")"
# Print renders the same page, then the system panel.
printed=$(count "web: printed")
key 'keystroke "p" using command down'
for _ in $(seq 60); do [ "$(count "web: printed")" -gt "$printed" ] && break; sleep 0.25; done
[ "$(count "web: printed")" -gt "$printed" ] || fail "print made no PDF"
sleep 2
key 'key code 53'
# Rich copy carries HTML; it overwrites the clipboard.
if [ "${ELY_CLIPBOARD_TEST:-}" = 1 ]; then
key 'keystroke "f" using command down'
key 'key code 53'
key 'keystroke "a" using command down'
key 'keystroke "c" using command down'
osascript -e 'clipboard info' | grep -q 'HTML' || fail "the copy carried no HTML: $(osascript -e 'clipboard info')"
fi
quit
# A large file: source alone, every byte kept and exported.
big=$work/big.md
python3 -c 'import sys
open(sys.argv[1],"w").write("".join(f"Line {n} of a large file.\n\n" for n in range(120000))+"LAST LINE\n")' "$big"
size=$(wc -c <"$big")
launch "$big"
waits 'mark: shown as "source"'
key 'keystroke "1" using command down'
waits "a large file stays in source"
key 'key code 125 using command down'
# A digit: an input method never holds it.
key 'keystroke "9"'
for _ in $(seq 40); do grep -q 'LAST LINE' "$big" && [ "$(wc -c <"$big")" -gt "$size" ] && break; sleep 0.25; done
[ "$(wc -c <"$big")" -gt "$size" ] && grep -q 'LAST LINE' "$big" && grep -q '^Line 0 of' "$big" || fail "the large file lost bytes"
export_as "Plain Text"
grep -q 'LAST LINE' "$work/big.txt" || fail "a large export was cut"
quit
echo "ok: mark output"
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env bash
# MARK-08, MARK-12, QA-22 live: outside changes, autosave, restore.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
work=$(mktemp -d)
app=
trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press; do
swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"
done
mkdir "$work/notes"
doc=$work/notes/t.md
printf 'one\n\ntwo\n' >"$doc"
launch() {
ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \
"$root/target/debug/ely-mark" "$@" >"$work/out" 2>"$work/err" &
app=$!
waits "ElyMark window open"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null
}
waits() {
for _ in $(seq 120); do
grep -qF "$1" "$work/err" && return 0
kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")"
sleep 0.25
done
fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)"
}
count() { grep -c "$1" "$work/err" || true; }
# Waits for `$1` to show more than `$2` times.
more() {
for _ in $(seq 120); do
[ "$(count "$1")" -gt "$2" ] && return 0
sleep 0.25
done
fail "no new '$1': $(grep -E 'mark:|recovery' "$work/err" | tail -n 6)"
}
# Types unpaused: a write lands within the autosave second.
now() { osascript -e "tell application \"System Events\" to keystroke \"$1\"" >/dev/null; }
key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; }
press() {
for _ in $(seq 20); do
[ "$("$work/ax-press" "$app" "$1")" = pressed ] && { sleep 0.8; return 0; }
sleep 0.25
done
fail "could not press $1"
}
# The window's own titles; system menus list recent files too.
seen() { "$work/ax-values" "$app" | grep -v '^AXMenu' | grep -qF "title=$1"; }
# Waits for an autosave past the `n` seen so far.
autosaved() {
for _ in $(seq 40); do
[ "$(count "autosaves")" -gt "$1" ] && sleep 1 && return 0
sleep 0.25
done
fail "no autosave: $(tr '\n' '/' <"$doc") $(grep -E 'mark:|office:' "$work/err" | tail -n 5)"
}
is() { [ "$(cat "$1")" = "$2" ] || fail "$3: $(cat "$1")"; }
launch "$doc"
waits 'mark: shown as "visual"'
[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
# A clean file follows an atomic replace; typing autosaves.
printf 'one\n\ntwo\n\nthree\n' >"$doc.tmp"
mv "$doc.tmp" "$doc"
waits "changed outside; unsaved false"
sleep 1
before=$(count autosaves)
key 'key code 125 using command down'
key 'keystroke "Z"'
autosaved "$before"
is "$doc" $'one\n\ntwo\n\nthreeZ' "reload then autosave"
# Unsaved text meets an outside write: nothing is overwritten.
now X
printf 'outside\n' >"$doc"
waits "changed outside; unsaved true"
sleep 2
is "$doc" "outside" "an outside write was overwritten"
press "Compare…"
seen "Use the Merge" || fail "no compare dialog"
press "Keep Yours"
waits "settles with Mine"
sleep 1
is "$doc" $'one\n\ntwo\n\nthreeZX' "keep yours"
# Autosave runs again once the user chose.
before=$(count autosaves)
key 'keystroke "K"'
autosaved "$before"
is "$doc" $'one\n\ntwo\n\nthreeZXK' "autosave after keep"
# Edits apart merge; a disk change mid-way compares again.
key 'key code 126 using command down'
conflicts=$(count "unsaved true")
now A
printf 'one\n\ntwo\n\nthreeZXK\n\ntail\n' >"$doc"
more "unsaved true" "$conflicts"
press "Compare…"
compares=$(count "compares tab")
printf 'one\n\ntwo\n\nthreeZXK\n\ntail\n\nmore\n' >"$doc"
press "Use the Merge"
waits "during the comparison"
more "compares tab" "$compares"
is "$doc" $'one\n\ntwo\n\nthreeZXK\n\ntail\n\nmore' "a stale merge was written"
press "Use the Merge"
waits "settles with Merged"
before=$(count autosaves)
autosaved "$before"
is "$doc" $'Aone\n\ntwo\n\nthreeZXK\n\ntail\n\nmore' "merge"
# A rename outside: the tab follows, saves the new name.
mv "$doc" "$work/notes/u.md"
waits "was renamed outside"
seen "u.md" || fail "the tab kept its old name"
before=$(count autosaves)
key 'keystroke "R"'
autosaved "$before"
[ ! -e "$doc" ] || fail "the old name came back"
grep -q '^RAone' "$work/notes/u.md" || fail "rename save: $(cat "$work/notes/u.md")"
# An unreadable change says so; a fresh look recovers.
clean=$(count "unsaved false")
printf 'locked\n' >"$work/notes/u.md"
chmod 000 "$work/notes/u.md"
for _ in $(seq 40); do seen "Check Again" && break; sleep 0.25; done
seen "Check Again" || fail "no offer to look again"
chmod 644 "$work/notes/u.md"
press "Check Again"
more "unsaved false" "$clean"
# A delete keeps the text and never recreates the file.
rm "$work/notes/u.md"
waits "was deleted outside"
seen "Save As…" || fail "no Save As offer"
before=$(count autosaves)
key 'keystroke "D"'
sleep 3
[ "$(count autosaves)" = "$before" ] || fail "autosave ran on a deleted file"
[ ! -e "$work/notes/u.md" ] || fail "autosave recreated a deleted file"
kill "$app"
wait "$app" 2>/dev/null || true
app=
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
# The killed run above left a crash; start clean.
rm -rf "$work/Library/Application Support/ElyOffice/ely-mark/"{session.json,session.lock,recovery}
# A clean quit keeps the tabs, mode, line and column.
printf 'kept\n\nsecond\n\nthird\n' >"$doc"
gone=$work/notes/w.md
printf 'other\n' >"$gone"
launch "$gone" "$doc"
more 'mark: shown as "visual"' 1
press t.md
key 'keystroke "f" using command down'
key 'key code 53'
key 'keystroke "2" using command down'
waits 'mark: shown as "source"'
key 'key code 125 using command down'
key 'key code 126'
key 'key code 123 using command down'
key 'key code 124'
key 'key code 124'
sleep 1
key 'keystroke "q" using command down'
for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done
! kill -0 "$app" 2>/dev/null || fail "Cmd+Q did not quit"
app=
launch
waits "restores tab"
waits 'mark: shown as "source"'
sleep 1
# Restored tabs open in any order; pick t.md's page.
press t.md
key 'keystroke "f" using command down'
key 'key code 53'
before=$(count autosaves)
key 'keystroke "Q"'
autosaved "$before"
is "$doc" $'kept\n\nsecond\n\nthQird' "the restored caret"
# Unsaved text, even a deleted file's, returns as copies.
points=$(count "recovery: a point")
press t.md
conflicts=$(count "unsaved true")
now Y
printf 'disk\n' >"$doc"
more "unsaved true" "$conflicts"
press w.md
# Closing the find box hands the keys to the page.
key 'keystroke "f" using command down'
key 'key code 53'
conflicts=$(count "unsaved true")
now V
printf 'gone\n' >"$gone"
more "unsaved true" "$conflicts"
rm "$gone"
more "recovery: a point" "$((points + 1))"
kill -9 "$app"
wait "$app" 2>/dev/null || true
app=
launch
waits "ended Crashed"
waits "did not open"
for _ in $(seq 40); do [ "$(count "toast .*unsaved text from")" -ge 2 ] && break; sleep 0.25; done
[ "$(count "toast .*unsaved text from")" = 2 ] || fail "recovered copies: $(grep "unsaved text from" "$work/err")"
sleep 3
is "$doc" "disk" "recovered text went into the file"
[ ! -e "$gone" ] || fail "a deleted file came back"
# Each copy holds its own text, past the first sweep.
rec="$work/Library/Application Support/ElyOffice/ely-mark/recovery"
session="$work/Library/Application Support/ElyOffice/ely-mark/session.json"
sleep 12
withy=$(grep -rlF Y "$rec" --include='*.bin' | head -n 1 || true)
withv=$(grep -rlF V "$rec" --include='*.bin' | head -n 1 || true)
[ -n "$withy" ] && [ -n "$withv" ] || fail "a copy lost its text: $(ls -R "$rec")"
! grep -qF V "$withy" && ! grep -qF Y "$withv" || fail "a copy took another's text"
# A cancelled Save panel stops the quit; the list follows.
key 'keystroke "q" using command down'
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
[ "$("$work/ax-press" "$app" Save AXButton)" = pressed ] || fail "no Save button"
sleep 2
key 'key code 53'
key 'keystroke "n" using command down'
sleep 2
points=$(count "recovery: a point")
key 'keystroke "third"'
more "recovery: a point" "$points"
for _ in $(seq 60); do [ "$(grep -c '"slot"' "$session")" = 3 ] && ! grep -q '"path": "' "$session" && break; sleep 0.5; done
[ "$(grep -c '"slot"' "$session")" = 3 ] && ! grep -q '"path": "' "$session" || fail "the kept list froze: $(tr -d " \n" <"$session")"
# A discard holds even through a crash right after.
key 'keystroke "q" using command down'
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
press "Don't Save"
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
press Cancel
kill -9 "$app"
wait "$app" 2>/dev/null || true
app=
launch
waits "ended Crashed"
sleep 4
[ "$(count "toast .*unsaved text from")" = 2 ] || fail "after a discard: $(grep "toast .*unsaved text from" "$work/err")"
key 'keystroke "q" using command down'
for _ in $(seq 4); do
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
seen "Don't Save" || break
press "Don't Save"
done
for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done
app=
# A discard is not a crash: nothing comes back.
launch
sleep 4
! grep -q "unsaved text from" "$work/err" || fail "discarded text came back"
kill "$app"
wait "$app" 2>/dev/null || true
app=
# A tab discarded, then a crash: it stays gone.
launch
key 'keystroke "n" using command down'
sleep 2
points=$(count "recovery: a point")
key 'keystroke "solo"'
more "recovery: a point" "$points"
[ "$("$work/ax-press" "$app" Close AXButton)" = pressed ] || fail "no tab Close"
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
press "Don't Save"
kill -9 "$app"
wait "$app" 2>/dev/null || true
app=
launch
waits "ended Crashed"
sleep 4
! grep -q "unsaved text from" "$work/err" || fail "a discarded tab came back"
kill "$app"
wait "$app" 2>/dev/null || true
app=
# A quit's conflict reloaded stops it; the list follows.
printf 'base\n' >"$doc"
launch "$doc"
waits 'mark: shown as "visual"'
conflicts=$(count "unsaved true")
now L
printf 'other\n' >"$doc"
more "unsaved true" "$conflicts"
key 'keystroke "q" using command down'
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
[ "$("$work/ax-press" "$app" Save AXButton)" = pressed ] || fail "no Save button"
for _ in $(seq 20); do seen "Reload from Disk" && break; sleep 0.25; done
press "Reload from Disk"
sleep 2
key 'keystroke "n" using command down'
sleep 2
points=$(count "recovery: a point")
key 'keystroke "afterreload"'
more "recovery: a point" "$points"
for _ in $(seq 60); do [ "$(grep -c '"slot"' "$session")" = 2 ] && break; sleep 0.5; done
[ "$(grep -c '"slot"' "$session")" = 2 ] || fail "the list froze after a reload: $(tr -d " \n" <"$session")"
key 'keystroke "q" using command down'
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
press "Don't Save"
for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done
app=
# A quit's first save keeps its file next time.
rm -f "$session"
launch
key 'keystroke "n" using command down'
sleep 2
key 'keystroke "savedtext"'
key 'keystroke "q" using command down'
for _ in $(seq 20); do seen "Don't Save" && break; sleep 0.25; done
[ "$("$work/ax-press" "$app" Save AXButton)" = pressed ] || fail "no Save button"
sleep 2
key 'keystroke "g" using {command down, shift down}'
key "keystroke \"$work/notes\""
key 'key code 36'
key 'key code 36'
for _ in $(seq 40); do kill -0 "$app" 2>/dev/null || break; sleep 0.25; done
! kill -0 "$app" 2>/dev/null || fail "the quit did not finish after saving"
app=
saved=$(grep -lF savedtext "$work/notes"/*.md || true)
[ -n "$saved" ] || fail "nothing saved: $(ls "$work/notes")"
launch
waits "restores tab"
kill "$app"
wait "$app" 2>/dev/null || true
app=
# A session kept before columns were still opens.
printf '{"tabs":[{"path":"%s","name":"t.md","mode":"source","place":{"line":1,"scroll":0},"slot":""}]}' "$doc" >"$session"
rm -f "$work/Library/Application Support/ElyOffice/ely-mark/session.lock"
launch
waits "restores tab"
waits 'mark: shown as "source"'
kill "$app"
wait "$app" 2>/dev/null || true
app=
echo "ok: mark outside"
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# MARK-05, MARK-10 live: folder tree, outline, search, recent.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
work=$(mktemp -d)
app=
trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press click; do
swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"
done
mkdir -p "$work/notes/sub"
# A heading inside a quote Milkdown rewrites: one raw block.
printf '# Alpha\n\nSome text.\n\n> ## Beta ##\n>\n> More words here.\n' >"$work/notes/a.md"
# A hit hidden in a link's address comes first.
printf '# Other\n\nwords [link](words)\nwords too\n' >"$work/notes/sub/b.md"
printf 'banana\nana too\n\n[link](words)\nwords two' >"$work/notes/d.md"
printf -- '---\ntitle: T\n---\n# Heading\n\nneedle here.\n\n# Other\n\nother paragraph.\n' >"$work/notes/c.md"
printf 'words\n' >"$work/notes/skip.txt"
# A file the search cannot read.
printf 'words\n' >"$work/notes/bad.md"
chmod 000 "$work/notes/bad.md"
launch() {
ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \
"$root/target/debug/ely-mark" "$@" >"$work/out" 2>"$work/err" &
app=$!
}
waits() {
for _ in $(seq 120); do
grep -qF "$1" "$work/err" && return 0
kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")"
sleep 0.25
done
fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)"
}
# Sets x and y to element `$1`'s center.
at() {
local found
for _ in $(seq 40); do
found=$("$work/ax-values" "$app" | awk -F'\t' -v want="title=$1" '$1 != "AXMenuItem" && $3 == want { sub("at=", "", $5); print $5; exit }')
if [ -n "$found" ]; then
IFS=, read -r x y <<<"$found"
return 0
fi
sleep 0.25
done
fail "no '$1' in the AX tree"
}
key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; }
press() { [ "$("$work/ax-press" "$app" "$1")" = pressed ] || fail "could not press $1"; sleep 0.8; }
saves() {
local before
before=$(grep -c "office: saved" "$work/err" || true)
key 'keystroke "s" using command down'
for _ in $(seq 40); do
[ "$(grep -c "office: saved" "$work/err" || true)" -gt "$before" ] && return 0
sleep 0.25
done
fail "no save"
}
launch "$work/notes" "$work/notes/a.md"
waits 'mark: shown as'
[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 1' >/dev/null
# Tree rows are tree items; Return opens a picked one.
at b.md
"$work/click" "$x" "$y"
key 'key code 36'
waits "opened b.md"
# An outline heading takes the caret there.
press a.md
press Outline
at Outline
"$work/click" "$((x - 64))" "$((y + 64))"
key 'keystroke "Z"'
saves
grep -qx '> ## ZBeta ##' "$work/notes/a.md" || fail "outline jump: $(cat "$work/notes/a.md")"
# Folder search finds lines; a hit opens there.
press Search
at Folder
"$work/click" "$x" "$y"
press "This file"
"$work/click" "$x" "$((y - 37))"
key 'keystroke "words"'
key 'key code 36'
press Folder
at "> More words here."
at "words too"
at "bad.md was not searched"
! "$work/ax-values" "$app" | grep -q "title=words " || fail "a .txt file was searched"
chmod 600 "$work/notes/bad.md"
# Inside a paragraph the caret lands on the hit's line.
press "words too"
key 'key code 36'
key 'keystroke "Q"'
saves
[ "$(cat "$work/notes/sub/b.md")" = $'# Other\n\nwords [link](words)\nQwords too' ] || fail "search hit: $(cat "$work/notes/sub/b.md")"
# Lines count from the file's top, past front matter.
press Search
at Folder
"$work/click" "$x" "$((y - 37))"
key 'keystroke "a" using command down'
key 'keystroke "needle"'
key 'key code 36'
at "needle here."
press "needle here."
key 'key code 36'
waits "opened c.md"
sleep 1
key 'keystroke "Q"'
saves
grep -qx 'Qneedle here.' "$work/notes/c.md" || fail "front matter offset: $(cat "$work/notes/c.md")"
# This file's results follow the shown tab.
press Search
press "This file"
at "Qneedle here."
press b.md
sleep 1.5
! "$work/ax-values" "$app" | grep -q "title=Qneedle here." || fail "results stayed with the last tab"
# Overlapping hits; a hit hidden in a link's address.
press Search
at Folder
"$work/click" "$x" "$((y - 37))"
key 'keystroke "a" using command down'
key 'keystroke "ana"'
key 'key code 36'
press Folder
press "ana too"
key 'key code 36'
waits "opened d.md"
sleep 1
key 'keystroke "Q"'
saves
[ "$(cat "$work/notes/d.md")" = $'banana\nQana too\n\n[link](words)\nwords two' ] || fail "overlapping hit: $(cat "$work/notes/d.md")"
press Search
at Folder
"$work/click" "$x" "$((y - 37))"
key 'keystroke "a" using command down'
key 'keystroke "words"'
key 'key code 36'
press Folder
press "[link](words)"
key 'key code 36'
sleep 1
key 'keystroke "R"'
saves
[ "$(cat "$work/notes/d.md")" = $'banana\nQana too\n\n[Rlink](words)\nwords two' ] || fail "hidden hit: $(cat "$work/notes/d.md")"
# A rename shows in the folder's tree.
press Files
key 'key code 120'
key 'keystroke "a" using command down'
key 'keystroke "moved.md"'
key 'key code 36'
waits "renamed"
at moved.md
! "$work/ax-values" "$app" | grep -q "^AXRow.*title=d.md " || fail "the tree kept the old name"
kill "$app"
wait "$app" 2>/dev/null || true
app=
# The recent list outlives the app.
python3 -c 'import json,sys; l=json.load(open(sys.argv[1])); sys.exit(0 if any(p.endswith("notes") for p in l) and any(p.endswith("a.md") for p in l) else 1)' \
"$(find "$work/Library" -name recent.json)" || fail "recent: $(cat "$(find "$work/Library" -name recent.json)")"
launch
waits 'ElyMark window open'
at notes
press notes
key 'key code 36'
waits "opens folder"
at Outline
kill "$app"
wait "$app" 2>/dev/null || true
app=
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
echo "ok: mark places"
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
# QA-21: a save keeps bytes no edit touched.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
work=$(mktemp -d)
app=
trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; chmod -R u+rw "$work"; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/press" "$root/tests/scripts/ax-press.swift"
waits() {
for _ in $(seq 200); do
grep -qF "$1" "$work/err" && return 0
kill -0 "$app" 2>/dev/null || break
sleep 0.25
done
fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)"
}
# Starts ElyMark on `file`; it must show visually.
launch() {
: >"$work/err"
ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" XDG_DATA_HOME="$work/data" \
"$root/target/debug/ely-mark" "$1" >"$work/out" 2>"$work/err" &
app=$!
}
shown() {
waits "mark: shown as"
grep -qF 'mark: shown as "visual", fallback false' "$work/err" ||
fail "not shown visually: $(grep 'mark: shown' "$work/err")"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" \
-e 'delay 0.5' >/dev/null
}
type() { osascript -e "tell application \"System Events\" to $1" -e 'delay 1' >/dev/null; }
save_quit() {
type 'keystroke "s" using command down'
waits "office: saved"
osascript -e 'tell application "System Events" to keystroke "q" using command down' >/dev/null
for _ in $(seq 40); do
kill -0 "$app" 2>/dev/null || break
sleep 0.25
done
! kill -0 "$app" 2>/dev/null || fail "ElyMark did not quit"
app=
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
}
# Saves `name` after `keys`; it must equal `want`.
check() {
local name=$1 text=$2 keys=$3 want=$4
printf '%s' "$text" >"$work/$name"
launch "$work/$name"
shown
[ -z "$keys" ] || type "$keys"
save_quit
[ "$(cat "$work/$name"; echo .)" = "$want." ] || fail "$name saved as: $(cat "$work/$name")"
}
# BOM, CRLF, front matter and syntax Milkdown rewrites.
python3 - "$work/keep.md" <<'PY'
import sys
text = """---
title: Keep
tags: [a, b]
---
First paragraph.
Setext
======
1) paren list
2) second
* star
* list
<div align="center">
<b>html</b>
</div>
See [[Wiki Link]], ~sub~ and [a ref][r].
next line
[r]: https://example.com "T"
Last paragraph.
"""
open(sys.argv[1], "wb").write(b"\xef\xbb\xbf" + text.replace("\n", "\r\n").encode())
PY
cp "$work/keep.md" "$work/plain.md"
launch "$work/plain.md"
shown
save_quit
cmp -s "$work/keep.md" "$work/plain.md" || fail "an unedited save changed bytes"
cp "$work/keep.md" "$work/edited.md"
launch "$work/edited.md"
shown
type 'keystroke "x"'
save_quit
python3 - "$work/keep.md" "$work/edited.md" <<'PY'
import sys
before, after = (open(path, "rb").read() for path in sys.argv[1:])
want = before.replace(b"First paragraph.", b"xFirst paragraph.", 1)
if after != want:
sys.exit(f"FAIL: the edit rewrote more than its block:\n{after!r}")
PY
# Blocks Milkdown would rewrite are edited as raw text.
check wiki.md $'Alpha with [[Wiki Link]].\n' 'keystroke "x"' $'xAlpha with [[Wiki Link]].\n'
quote=$'> First.\n>\n> Setext\n> ======\n>\n> Last with [[Wiki]].\n'
check quote.md "$quote" 'keystroke "x"' "x$quote"
check defs.md $'[r]: https://example.com\n' 'keystroke "x"' $'x[r]: https://example.com\n'
# Deleting forward at a hidden definition moves nothing.
gap=$'Alpha.\n\n[r]: /path " T " \n\nBeta.\n\nTail.\n'
check gap.md "$gap" 'key code 119' "$gap"
printf '%s' "$gap" >"$work/gap2.md"
launch "$work/gap2.md"
shown
type 'key code 119'
type 'key code 117'
save_quit
[ "$(cat "$work/gap2.md"; echo .)" = "$gap." ] || fail "the definition moved: $(cat "$work/gap2.md")"
# A reloaded page's edits still save.
printf 'Alpha.\n' >"$work/crash.md"
launch "$work/crash.md"
shown
# Two versions first: the new page counts from one.
type 'keystroke "x"'
type 'keystroke "z"'
pkill -P "$app" -f -- "--type=renderer" || fail "no renderer to stop"
pressed=
for _ in $(seq 40); do
[ "$("$work/press" "$app" Reload)" = pressed ] && pressed=1 && break
sleep 0.25
done
[ -n "$pressed" ] || fail "no Reload button"
for _ in $(seq 80); do
[ "$(grep -c 'mark: shown as' "$work/err")" -ge 2 ] && break
sleep 0.25
done
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" >/dev/null
type 'keystroke "y"'
save_quit
grep -q "y" "$work/crash.md" || fail "an edit after reload was lost: $(cat "$work/crash.md")"
# An unreadable file says so; no password is asked.
printf 'secret\n' >"$work/denied.md"
chmod 000 "$work/denied.md"
launch "$work/denied.md"
waits "did not open"
grep -q "Permission denied" "$work/err" || fail "the reason is missing: $(grep 'did not open' "$work/err")"
! grep -q "locked" "$work/err" || fail "taken for a locked file"
kill "$app"
app=
echo "ok: mark session"
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# MARK-06, MARK-07 live: find, replace, Tab, lists, tables, images.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
command -v npm >/dev/null || { echo "SKIP: no npm"; exit 0; }
work=$(mktemp -d)
app=
trap 'if [ -n "$app" ]; then kill "$app" 2>/dev/null || true; wait "$app" 2>/dev/null || true; fi; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
"$root/scripts/build-mark-content.sh" >"$work/build" 2>&1 || fail "build: $(tail -n 5 "$work/build")"
cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press click drop; do
swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"
done
mkdir "$work/notes"
printf 'alpha beta alpha\n\ngamma alpha\n' >"$work/notes/t.md"
# A two-pixel PNG.
python3 -c 'import struct,zlib,sys
c=lambda t,d: struct.pack(">I",len(d))+t+d+struct.pack(">I",zlib.crc32(t+d))
open(sys.argv[1],"wb").write(b"\x89PNG\r\n\x1a\n"+c(b"IHDR",struct.pack(">IIBBBBB",2,2,8,2,0,0,0))+c(b"IDAT",zlib.compress(b"\0"+b"\xff\0\0"*2+b"\0"+b"\0\xff\0"*2))+c(b"IEND",b""))' "$work/red pic.png"
launch() {
ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$work" \
"$root/target/debug/ely-mark" "$@" >"$work/out" 2>"$work/err" &
app=$!
}
waits() {
for _ in $(seq 120); do
grep -qF "$1" "$work/err" && return 0
kill -0 "$app" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/err")"
sleep 0.25
done
fail "never saw '$1': $(grep -E 'mark|office' "$work/err" | tail -n 5)"
}
key() { osascript -e "tell application \"System Events\" to $1" -e 'delay 0.8' >/dev/null; }
press() {
for _ in $(seq 20); do
[ "$("$work/ax-press" "$app" "$1")" = pressed ] && { sleep 0.8; return 0; }
sleep 0.25
done
fail "could not press $1"
}
# Clicks the find box's `$1`th text field, top first.
field() {
local found
found=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXTextField" { sub("at=", "", $5); print $5 }' | sort -t, -k2 -n | sed -n "$1p")
[ -n "$found" ] || fail "no find field $1"
IFS=, read -r x y <<<"$found"
"$work/click" "$x" "$y"
sleep 0.5
}
saves() {
local before
before=$(grep -c "office: saved" "$work/err" || true)
key 'keystroke "s" using command down'
for _ in $(seq 40); do
[ "$(grep -c "office: saved" "$work/err" || true)" -gt "$before" ] && return 0
sleep 0.25
done
fail "no save"
}
is() { [ "$(cat "$work/notes/t.md")" = "$1" ] || fail "$2: $(cat "$work/notes/t.md")"; }
launch "$work/notes/t.md"
waits 'mark: shown as "visual"'
[ "$("$work/ax-press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null
# Replace all in the visual editor.
key 'keystroke "f" using command down'
key 'keystroke "alpha"'
press "Toggle replace"
field 2
key 'keystroke "omega"'
press "Replace all"
saves
is $'omega beta omega\n\ngamma omega' "replace all"
# A regular expression in the source editor.
press Source
waits 'mark: shown as "source"'
field 1
key 'keystroke "a" using command down'
key 'keystroke "g.mma"'
press "Regular expression"
field 2
key 'keystroke "a" using command down'
key 'keystroke "delta"'
press Replace
saves
is $'omega beta omega\n\ndelta omega' "regex replace"
# Tab indents the selected line; Esc closes the box first.
field 1
key 'key code 53'
key 'key code 48'
saves
grep -qx '[[:space:]]\{1,\}delta omega' "$work/notes/t.md" || fail "tab: $(cat "$work/notes/t.md")"
key 'key code 48 using shift down'
saves
is $'omega beta omega\n\ndelta omega' "shift-tab"
# A list item continues on Return.
key 'key code 125 using command down'
key 'key code 36'
key 'keystroke "- one"'
key 'key code 36'
key 'keystroke "two"'
saves
grep -qx -- '- two' "$work/notes/t.md" || fail "list: $(cat "$work/notes/t.md")"
# Tables: insert, then a column and a row.
press Visual
waits 'mark: shown as "visual"'
# Closing the find box hands the keys to the page.
key 'keystroke "f" using command down'
key 'key code 53'
# Command arrows move though AppKit sends no edit command.
key 'key code 125 using command down'
press "Insert Table"
press "Add Column Right"
press "Add Row Below"
saves
header=$(grep -m1 '^ *|' "$work/notes/t.md" || true)
[ "$(tr -cd '|' <<<"$header" | wc -c | tr -d ' ')" = 5 ] || fail "table columns: $(cat "$work/notes/t.md")"
[ "$(grep -c '^ *|' "$work/notes/t.md")" = 5 ] || fail "table rows: $(cat "$work/notes/t.md")"
[ "$(head -n 1 "$work/notes/t.md")" = "omega beta omega" ] || fail "table not at the end: $(cat "$work/notes/t.md")"
press "Delete Column"
saves
header=$(grep -m1 '^ *|' "$work/notes/t.md" || true)
[ "$(tr -cd '|' <<<"$header" | wc -c | tr -d ' ')" = 4 ] || fail "delete column: $(cat "$work/notes/t.md")"
# A dropped image is copied beside the file, name escaped.
window=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXWindow" { sub("at=", "", $5); print $5; exit }')
IFS=, read -r x y <<<"$window"
[ "$("$work/drop" "$work/red pic.png" "$x" "$y")" = dropped ] || fail "the drop was refused"
waits "mark: attached 1 images"
saves
cmp -s "$work/red pic.png" "$work/notes/assets/red pic.png" || fail "dropped copy: $(ls -R "$work/notes")"
grep -qF '](assets/red%20pic.png)' "$work/notes/t.md" || fail "drop link: $(cat "$work/notes/t.md")"
# A pasted image lands in assets beside the file.
if [ "${ELY_CLIPBOARD_TEST:-}" = 1 ]; then
osascript -e "set the clipboard to (read (POSIX file \"$work/red pic.png\") as «class PNGf»)"
key 'key code 125 using command down'
key 'key code 36'
key 'keystroke "v" using command down'
saves
png=$(ls "$work/notes/assets"/pasted-*.png 2>/dev/null | head -n 1)
[ -n "$png" ] || fail "no pasted image: $(ls -R "$work/notes")"
grep -qF "](assets/$(basename "$png"))" "$work/notes/t.md" || fail "image link: $(cat "$work/notes/t.md")"
fi
# Saved elsewhere mid-copy: no link to nowhere.
mkfifo "$work/slow.png"
mkdir "$work/elsewhere"
window=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXWindow" { sub("at=", "", $5); print $5; exit }')
IFS=, read -r x y <<<"$window"
[ "$("$work/drop" "$work/slow.png" "$x" "$y")" = dropped ] || fail "the slow drop was refused"
press "Save As…"
key 'keystroke "g" using {command down, shift down}'
key "keystroke \"$work/elsewhere\""
key 'key code 36'
key 'key code 36'
waits "elsewhere/t.md"
(printf 'x' >"$work/slow.png" &)
waits "the file moved while images copied"
! grep -q "slow" "$work/elsewhere/t.md" || fail "a link to the old folder: $(cat "$work/elsewhere/t.md")"
kill "$app"
wait "$app" 2>/dev/null || true
app=
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
# Two tabs; typing a replacement keeps the hit.
printf 'alpha beta alpha beta alpha\n' >"$work/notes/v.md"
printf 'alpha beta\n' >"$work/notes/w.md"
cp "$work/red pic.png" "$work/a]b.png"
launch "$work/notes/v.md" "$work/notes/w.md"
waits 'mark: shown as "visual"'
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $app) to true" -e 'delay 2' >/dev/null
press v.md
sleep 1
key 'keystroke "f" using command down'
key 'keystroke "alpha"'
press "Toggle replace"
field 2
key 'keystroke "XY"'
press Replace
saves
[ "$(cat "$work/notes/v.md")" = "XY beta alpha beta alpha" ] || fail "the hit moved: $(cat "$work/notes/v.md")"
# Each tab follows the box, not its last query.
press w.md
field 1
key 'keystroke "a" using command down'
key 'keystroke "beta"'
field 2
key 'keystroke "a" using command down'
key 'keystroke "Y"'
press v.md
field 1
key 'keystroke "a" using command down'
key 'keystroke "alpha"'
field 2
key 'keystroke "a" using command down'
key 'keystroke "Z"'
press w.md
press "Replace all"
saves
[ "$(cat "$work/notes/w.md")" = "Z beta" ] || fail "a stale query: $(cat "$work/notes/w.md")"
# Alt text in source closes no brackets.
press Source
waits 'mark: shown as "source"'
window=$("$work/ax-values" "$app" | awk -F'\t' '$1 == "AXWindow" { sub("at=", "", $5); print $5; exit }')
IFS=, read -r x y <<<"$window"
[ "$("$work/drop" "$work/a]b.png" "$x" "$y")" = dropped ] || fail "the bracket drop was refused"
waits "mark: attached 1 images"
saves
grep -qF '![a\]b](assets/a%5Db.png)' "$work/notes/w.md" || fail "alt escape: $(cat "$work/notes/w.md")"
kill "$app"
wait "$app" 2>/dev/null || true
app=
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
echo "ok: mark tools"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# The media player interface compiles for Linux and Windows too.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
targets=(x86_64-unknown-linux-gnu x86_64-pc-windows-msvc)
installed=$(cd "$root" && rustup target list --installed)
for target in "${targets[@]}"; do
if ! grep -qx "$target" <<<"$installed"; then
echo "SKIP: media-targets needs rustup target $target"
exit 0
fi
done
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
mkdir -p "$work/src"
cp "$root/rust-toolchain.toml" "$work/"
cat >"$work/Cargo.toml" <<TOML
[package]
name = "media-targets"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
log = "0.4"
TOML
cat >"$work/src/lib.rs" <<RS
#[path = "$root/crates/ely-platform/src/media.rs"]
pub mod media;
// Every call ElyShow makes.
pub fn touch(path: &std::path::Path) -> anyhow::Result<Option<media::Frame>> {
let player = media::Player::open(path)?;
let _: media::State = player.state();
let (_, _): (bool, f64) = (player.playing(), player.position());
player.play();
player.pause();
player.seek(0.0);
Ok(player.frame())
}
RS
for target in "${targets[@]}"; do
(cd "$work" && cargo check --quiet --target "$target") \
|| { echo "FAIL: media does not compile for $target"; exit 1; }
done
echo "media-targets: the player interface compiles for ${targets[*]}"
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Checks a saved DOCX, XLSX or PPTX without LibreOffice."""
import datetime
import math
import posixpath
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
import xml.etree.ElementTree as ET
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
S = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
P = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
failures = []
def check(ok, what):
if not ok:
failures.append(what)
return ok
def xml(archive, name):
return ET.fromstring(archive.read(name))
def texts(element, tag):
return "".join(node.text or "" for node in element.iter(tag))
def rels(archive, part):
"""A part's relationships: id to (type's last word, target part)."""
folder, name = posixpath.split(part)
path = posixpath.join(folder, "_rels", name + ".rels")
if path not in archive.namelist():
return {}
found = {}
for rel in xml(archive, path).iter(REL + "Relationship"):
target = rel.get("Target")
if rel.get("TargetMode") != "External":
target = posixpath.normpath(posixpath.join(folder, target)).lstrip("/")
found[rel.get("Id")] = (rel.get("Type").rsplit("/", 1)[-1], target)
return found
def related(archive, part, kind):
"""The parts `part` relates to as `kind`; each must exist."""
targets = [target for got, target in rels(archive, part).values() if got == kind]
for target in targets:
check(target in archive.namelist(), f"{part} relates to a missing {target}")
return [target for target in targets if target in archive.namelist()]
def main_part(archive):
parts = related(archive, "", "officeDocument")
check(len(parts) == 1, "one main part")
return parts[0] if parts else None
def on(element):
"""An OOXML on/off element: absent value means on."""
return element is not None and element.get(W + "val", "true") in ("true", "1", "on")
def before_references(body):
"""Each comment reference's id, with its paragraph's text before it."""
placed = {}
for paragraph in body.iter(W + "p"):
seen = ""
for node in paragraph.iter():
if node.tag == W + "t":
seen += node.text or ""
elif node.tag == W + "commentReference":
placed[node.get(W + "id")] = seen
return placed
def contract(path):
"""Typed text, a comment, a tracked insertion, both sessions."""
with zipfile.ZipFile(path) as archive:
document = main_part(archive)
if not document:
return
body = xml(archive, document)
paragraphs = [texts(p, W + "t") for p in body.iter(W + "p")]
whole = "\n".join(paragraphs)
for typed in ["Alpha 甲", "Hello 世界", "Gamma"]:
check(typed in whole, f"document text has {typed!r}: {paragraphs}")
inserted = "".join(texts(ins, W + "t") for ins in body.iter(W + "ins"))
for tracked in ["Beta", "Gamma"]:
check(tracked in inserted, f"{tracked} is a tracked insertion, not {inserted!r}")
placed = before_references(body)
comments = related(archive, document, "comments")
if check(len(comments) == 1, "the document relates to one comment part"):
said = {c.get(W + "id"): texts(c, W + "t") for c in xml(archive, comments[0]).iter(W + "comment")}
ours = [id_ for id_, text in said.items() if "Check alpha" in text]
check(len(ours) == 1, f"one comment says Check alpha: {said}")
for id_ in ours:
at = placed.get(id_, "")
check(at.rstrip().endswith("Alpha 甲"), f"comment {id_} follows Alpha 甲, not {at[-20:]!r}")
# Made at a point: no range markers.
ranged = [n for n in body.iter() if n.tag in (W + "commentRangeStart", W + "commentRangeEnd") and n.get(W + "id") == id_]
check(not ranged, f"comment {id_} stays a point, not a range")
settings = related(archive, document, "settings")
if check(len(settings) == 1, "the document relates to its settings"):
check(on(xml(archive, settings[0]).find(W + "trackRevisions")), "tracking stays on")
# Apple's own DOCX reader, a second independent opinion.
if shutil.which("textutil"):
shown = subprocess.run(["textutil", "-convert", "txt", "-stdout", path],
capture_output=True, text=True, check=True).stdout
check("Alpha 甲" in shown and "Hello 世界" in shown, f"textutil reads the text: {shown!r}")
def cells(archive):
"""The workbook's first declared sheet, through its relationship."""
book = main_part(archive)
if not book:
return {}
shared = []
for part in related(archive, book, "sharedStrings"):
shared = [texts(si, S + "t") for si in xml(archive, part).iter(S + "si")]
sheets = list(xml(archive, book).iter(S + "sheet"))
if not check(sheets, "the workbook lists a sheet"):
return {}
target = rels(archive, book).get(sheets[0].get(R + "id"))
if not check(target and target[1] in archive.namelist(), f"the first sheet's part exists: {target}"):
return {}
found = {}
for c in xml(archive, target[1]).iter(S + "c"):
kind = c.get("t", "n")
f, v = c.find(S + "f"), c.find(S + "v")
value = v.text if v is not None else None
if kind == "s" and value is not None:
value = shared[int(value)]
elif kind == "inlineStr":
value = texts(c, S + "t")
found[c.get("r")] = (kind, f.text if f is not None else None, value)
return found
def sheet(path):
"""Inputs, formulas and cached results against Python's own sums."""
h1, h2 = 7, 5
serial = (datetime.date(2024, 2, 29) - datetime.date(1899, 12, 30)).days
# Exact: integers, dates, errors, text. Floats: the case's tolerance.
expected = {
"H1": (None, "n", h1, 0),
"H2": (None, "n", h2, 0),
"H3": ("H1*H2", "n", h1 * h2, 0),
"H4": ("H1/3", "n", h1 / 3, 1e-12),
"H5": ("DATE(2024,2,29)", "n", serial, 0),
"H6": ("1/0", "e", "#DIV/0!", None),
"H7": ('"Ely"&"-"&TEXT(H1,"0")', "str", f"Ely-{h1}", None),
"H8": ("SUM(H1:H2)", "n", h1 + h2, 0),
"H9": ("H3+1", "n", h1 * h2 + 1, 0),
}
with zipfile.ZipFile(path) as archive:
found = cells(archive)
for ref, (formula, kind, value, tolerance) in expected.items():
if not check(ref in found, f"{ref} is there"):
continue
got_kind, got_formula, got = found[ref]
check(got_formula == formula, f"{ref} formula {got_formula!r}, not {formula!r}")
check(got_kind == kind, f"{ref} type {got_kind!r}, not {kind!r}")
if tolerance is None:
check(got == value, f"{ref} is {got!r}, not {value!r}")
elif tolerance == 0:
check(got is not None and float(got) == value and float(got).is_integer(), f"{ref} is {got!r}, not {value}")
else:
check(got is not None and math.isclose(float(got), value, rel_tol=0, abs_tol=tolerance),
f"{ref} is {got!r}, not {value} within {tolerance}")
def notes_body(notes):
"""The text of a notes page's body placeholder, the editable notes."""
found = []
for shape in notes.iter(P + "sp"):
holder = shape.find(f"{P}nvSpPr/{P}nvPr/{P}ph")
if holder is not None and holder.get("type") == "body":
found.append(texts(shape, A + "t"))
return " ".join(found)
def deck(path):
"""Slides in order, a hidden slide, notes on their slide."""
with zipfile.ZipFile(path) as archive:
show = main_part(archive)
if not show:
return
links = rels(archive, show)
order = [sid.get(R + "id") for sid in xml(archive, show).iter(P + "sldId")]
slides = []
for rid in order:
target = links.get(rid)
if check(target and target[1] in archive.namelist(), f"slide {rid} has its part: {target}"):
slides.append(target[1])
check(len(order) == 3, f"three slides, not {len(order)}")
if not slides:
return
check(len(set(slides)) == len(slides), f"each slide its own part: {slides}")
# The fixture: First, the inserted blank, the hidden Second.
said = [texts(xml(archive, part), A + "t").strip() for part in slides]
check(said == ["First", "", "Second"], f"slides in order say {said}")
hidden = [xml(archive, part).get("show") == "0" for part in slides]
check(hidden == [False, False, True], f"only the last slide is hidden: {hidden}")
spoken = [" ".join(notes_body(xml(archive, n)) for n in related(archive, part, "notesSlide")) for part in slides]
check("Speaker 甲" in spoken[0], f"slide one's notes say Speaker 甲, not {spoken[0]!r}")
check(not any("Speaker" in text for text in spoken[1:]), f"only slide one has the notes: {spoken}")
CASES = {"contract": contract, "sheet": sheet, "deck": deck}
# Damage each check must catch: part, pattern, replacement.
MUTATIONS = {
"contract": [
("word/document.xml", r"<w:commentReference [^>]*/>", ""),
("word/settings.xml", r"<w:trackRevisions/>", '<w:trackRevisions w:val="false"/>'),
("word/document.xml", r'(?s)<w:commentReference w:id="0"/>((?:(?!</w:p>).)*)</w:p>',
r'\1<w:r><w:commentReference w:id="0"/></w:r></w:p>'),
("word/document.xml", r'(?s)^(.*?<w:p\b[^>]*>)', r'\1<w:commentRangeStart w:id="0"/>'),
("word/document.xml", r'(?s)<w:ins\b[^>]*>((?:(?!</w:ins>).)*Gamma(?:(?!</w:ins>).)*)</w:ins>', r"\1"),
],
"sheet": [
("xl/worksheets/sheet1.xml", r'(<c r="H1"[^>]*><v>)7(</v>)', r"\g<1>70\g<2>"),
("xl/_rels/workbook.xml.rels", r"worksheets/sheet1\.xml", "worksheets/missing.xml"),
],
"deck": [
("ppt/_rels/presentation.xml.rels", r"slides/slide2\.xml", "slides/missing.xml"),
("ppt/_rels/presentation.xml.rels", r"slides/slide2\.xml", "slides/slide1.xml"),
("ppt/presentation.xml", r'(?s)r:id="rId5"(.*?)r:id="rId6"', r'r:id="rId6"\1r:id="rId5"'),
("ppt/notesSlides/notesSlide1.xml", r'<p:ph type="body"', '<p:ph type="ftr"'),
("ppt/slides/_rels/slide1.xml.rels", r"<Relationship [^>]*notesSlide\"[^>]*/>", ""),
],
}
def selftest(case, path):
"""Each mutation of a good file must fail the check."""
global failures
missed = []
for part, pattern, replacement in MUTATIONS[case]:
with zipfile.ZipFile(path) as archive:
entries = {name: archive.read(name) for name in archive.namelist()}
text = entries[part].decode()
changed, count = re.subn(pattern, replacement, text)
if count == 0:
missed.append(f"{part}: {pattern} found nothing to change")
continue
entries[part] = changed.encode()
with tempfile.NamedTemporaryFile(suffix=path[-5:]) as copy:
with zipfile.ZipFile(copy.name, "w") as archive:
for name, body in entries.items():
archive.writestr(name, body)
failures = []
CASES[case](copy.name)
if not failures:
missed.append(f"{part}: {pattern} passed")
failures = [f"a mutation went unnoticed: {what}" for what in missed]
if __name__ == "__main__":
if sys.argv[1] == "selftest":
selftest(sys.argv[2], sys.argv[3])
else:
CASES[sys.argv[1]](sys.argv[2])
for failure in failures:
print(f"FAIL: {failure}")
sys.exit(1 if failures else 0)
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Side panels: narrower by default, dragged wider, kept on restart.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
# Keeps the run's own status; the app's SIGTERM is expected.
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
[ "$status" = 0 ] && rm -rf "$work" || echo "kept $work" >&2
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press drag; do swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"; done
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
mkdir -p "$home"
prefs="$home/Library/Application Support/ElyOffice/ely-doc/settings.json"
start() {
ELY_SMOKE=hold ELY_LOCALE=en HOME="$home" perl -e 'alarm 120; exec @ARGV' "$root/target/debug/ely-doc" >"$work/out" 2>&1 &
pid=$!
for _ in $(seq 100); do "$work/ax-values" "$pid" | grep -q 'title=Back' && break; sleep 0.3; done
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
}
# The window's left edge and Back's center.
edges() {
local window back
window=$(osascript -e "tell application \"System Events\" to get position of first window of (first process whose unix id is $pid)" | cut -d, -f1)
back=$("$work/ax-values" "$pid" | grep -m 1 'title=Back' | sed 's/.*at=//' | cut -d, -f1)
echo "$window $back"
}
start
read -r left back <<<"$(edges)"
# Back is centered in the left panel: half its width.
[ $(( (back - left) * 2 )) -le 230 ] || fail "the left panel starts at $(( (back - left) * 2 )) points"
y=$(( $(osascript -e "tell application \"System Events\" to get position of first window of (first process whose unix id is $pid)" | cut -d, -f2 | tr -d ' ') + 400 ))
edge=$(( left + (back - left) * 2 - 1 ))
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
quit() {
keys 'keystroke "q" using command down'
for _ in $(seq 40); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done
! kill -0 "$pid" 2>/dev/null || fail "Cmd+Q did not quit"
wait "$pid" 2>/dev/null || true
}
# Waits until the app's AX tree holds $1.
seen() {
for _ in $(seq 40); do "$work/ax-values" "$pid" | grep -q -- "$1" && return 0; sleep 0.25; done
return 1
}
# Dragged, then quit at once: the width is still kept.
"$work/drag" "$edge" "$y" $(( edge + 80 )) "$y"
read -r _ wide <<<"$(edges)"
[ $(( wide - back )) -ge 30 ] || fail "the panel did not widen: Back moved $(( wide - back ))"
quit
grep -q '"left": [0-9]' "$prefs" || fail "a quit right after the drag lost the width: $(cat "$prefs" 2>/dev/null)"
start
read -r left again <<<"$(edges)"
[ $(( again - wide )) -le 2 ] && [ $(( wide - again )) -le 2 ] || fail "the width did not come back: $again, not $wide"
quit
# A bad stored width is shown, not silently replaced.
python3 - "$prefs" <<'PY'
import json, sys
path = sys.argv[1]
fields = json.load(open(path))
fields["panels"] = {"left": "broken", "right": 320}
open(path, "w").write(json.dumps(fields))
PY
start
keys 'keystroke "," using command down'
seen "settings cannot be read" || fail "the bad width is not shown"
# A dragged width mends it; the message goes.
osascript -e "tell application \"System Events\" to click button 1 of (first window of (first process whose unix id is $pid) whose name is \"Settings\")" >/dev/null
sleep 0.5
read -r left back <<<"$(edges)"
edge=$(( left + (back - left) * 2 - 1 ))
"$work/drag" "$edge" "$y" $(( edge + 30 )) "$y"
for _ in $(seq 20); do grep -q '"left": [0-9]' "$prefs" 2>/dev/null && break; sleep 0.25; done
grep -q '"left": [0-9]' "$prefs" || fail "the mending width is not kept"
keys 'keystroke "," using command down'
seen "title=Dark" || fail "the settings page did not load"
! "$work/ax-values" "$pid" | grep -q "cannot be read" || fail "the mended width still shows as unreadable"
quit
# A failed save is shown.
printf '{"version":1}' >"$prefs"
start
read -r left back <<<"$(edges)"
edge=$(( left + (back - left) * 2 - 1 ))
rm "$prefs"
mkdir "$prefs"
"$work/drag" "$edge" "$y" $(( edge + 60 )) "$y"
sleep 1
keys 'keystroke "," using command down'
seen "was not saved" || fail "the failed save is not shown"
# Another kind of save succeeding leaves this failure shown.
"$work/ax-press" "$pid" Dark >/dev/null
sleep 1.5
grep -q '"appearance": "dark"' "$home/Library/Application Support/ElyOffice/settings.json" || fail "Dark was not saved"
seen "was not saved" || fail "the panel failure vanished on another save"
# Once the panel save works again, the failure goes.
rmdir "$prefs"
# Out of the way: the settings window covers the edge.
osascript -e "tell application \"System Events\" to click button 1 of (first window of (first process whose unix id is $pid) whose name is \"Settings\")" >/dev/null
sleep 0.5
read -r left back <<<"$(edges)"
edge=$(( left + (back - left) * 2 - 1 ))
"$work/drag" "$edge" "$y" $(( edge + 30 )) "$y"
for _ in $(seq 20); do grep -q '"left": [0-9]' "$prefs" 2>/dev/null && break; sleep 0.25; done
grep -q '"left": [0-9]' "$prefs" || fail "the retried width is not kept"
keys 'keystroke "," using command down'
# The page's own controls first: an empty tree proves nothing.
seen "title=Dark" || fail "the settings page did not load"
! "$work/ax-values" "$pid" | grep -q "was not saved" || fail "the fixed failure still shows"
quit
echo "ok: panels"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# ElyPDF with no file starts no MuPDF worker (PERF-01).
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
(cd "$root" && cargo build -q -p ely-pdf -p ely-pdf-engine)
ELY_NO_DIALOG=1 ELY_SMOKE=hold "$root/target/debug/ely-pdf" >/dev/null 2>&1 &
app=$!
trap 'kill $app 2>/dev/null || true' EXIT
sleep 3
kill -0 "$app" || { echo "FAIL: ElyPDF did not stay up"; exit 1; }
if pgrep -P "$app" -f ely-pdf-worker >/dev/null; then
echo "FAIL: a worker runs with no file open"
exit 1
fi
echo "pdf-idle: no worker before the first file"
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# ElyPDF: a locked file keeps sealed points past engine ends.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
pkill -9 -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
[ "$status" = 0 ] && rm -rf "$work" || echo "kept $work" >&2
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-pdf-engine --bins -p ely-pdf --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press; do swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"; done
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
data="$home/Library/Application Support/ElyOffice/ely-pdf"
mkdir -p "$home"
cp "$root/tests/fixtures/pdf/aes128.pdf" "$work/locked.pdf"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
seen() {
for _ in $(seq 80); do "$work/ax-values" "$pid" | grep -q -- "$1" && return 0; sleep 0.25; done
return 1
}
opened() { { grep -c "ely-pdf: opened" "$data/ely.log" 2>/dev/null || true; } | head -n 1; }
start() {
ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" perl -e 'alarm 300; exec @ARGV' \
"$root/target/debug/ely-pdf" "$@" >"$work/out" 2>&1 &
pid=$!
for _ in $(seq 40); do "$work/ax-values" "$pid" | grep -q AXWindow && break; sleep 0.25; done
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
}
# The open-password dialog, answered; then the tab is there.
unlock() {
local before
before=$(opened)
seen "is locked" || fail "no password asked ($1)"
keys 'keystroke "owner"'
keys 'key code 36'
for _ in $(seq 80); do [ "$(opened)" -gt "${before:-0}" ] && break; sleep 0.25; done
[ "$(opened)" -gt "${before:-0}" ] || fail "the locked file did not open ($1)"
}
# A new sealed point, and never a plain one.
sealed() {
touch "$work/mark"
for _ in $(seq 100); do
find "$data/recovery" -name '*.enc' -newer "$work/mark" 2>/dev/null | grep -q . && break
sleep 0.25
done
find "$data/recovery" -name '*.enc' -newer "$work/mark" | grep -q . || fail "no sealed point ($1)"
[ -z "$(find "$data/recovery" -name '*.bin')" ] || fail "a plain point of a locked file ($1)"
}
start "$work/locked.pdf"
unlock "the file"
osascript -e "tell application \"System Events\" to tell (first process whose unix id is $pid) to click menu item \"Rotate Pages Right\" of menu \"Pages\" of menu bar 1" >/dev/null
sealed "while editing"
# The idle engine ends: told; the copy asks again.
pkill -9 -P "$pid" -f ely-pdf-worker || fail "no worker to stop"
seen "The document engine stopped" || fail "an idle engine's end is not told"
"$work/ax-press" "$pid" "Reopen documents" >/dev/null
unlock "the copy after the engine ended"
[ -n "$(find "$data/recovery" -name '*.enc')" ] || fail "the old points went before a new one"
grep -q "the engine is back, 0 tab(s) lost" "$data/ely.log" || fail "a tab is lost"
sealed "for the reopened copy"
echo "ok: pdf recovery"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# ElyDoc print: the document's paper and scale, cancel changes nothing.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
command -v pdftotext >/dev/null && command -v pdfinfo >/dev/null || { echo "SKIP: needs poppler"; exit 0; }
work=$(mktemp -d)
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
pkill -9 -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
[ "$status" = 0 ] && rm -rf "$work" || echo "kept $work" >&2
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press; do swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"; done
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
data="$home/Library/Application Support/ElyOffice/ely-doc"
mkdir -p "$home"
# A5 landscape, and A5 then A3; written without LibreOffice.
python3 - "$work" <<'PY'
import sys, zipfile
w = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
margins = '<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/>'
a5 = f'<w:pgSz w:w="11906" w:h="8391" w:orient="landscape"/>{margins}'
a3 = f'<w:pgSz w:w="23811" w:h="16838" w:orient="landscape"/>{margins}'
thin = '<w:pgMar w:top="360" w:right="720" w:bottom="360" w:left="720"/>'
wide = f'<w:pgSz w:w="20000" w:h="4000" w:orient="landscape"/>{thin}'
square = f'<w:pgSz w:w="12000" w:h="12000"/>{thin}'
bodies = {
"a5.docx": f'<w:p><w:r><w:t>Print me</w:t></w:r></w:p><w:sectPr>{a5}</w:sectPr>',
"mixed.docx": f'<w:p><w:pPr><w:sectPr>{a5}</w:sectPr></w:pPr><w:r><w:t>Small</w:t></w:r></w:p>'
f'<w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:t>Edge</w:t></w:r></w:p><w:sectPr>{a3}</w:sectPr>',
"crossed.docx": f'<w:p><w:r><w:t>Begin</w:t></w:r></w:p>'
f'<w:p><w:pPr><w:jc w:val="right"/><w:sectPr>{wide}</w:sectPr></w:pPr><w:r><w:t>Right</w:t></w:r></w:p>'
f'<w:p><w:r><w:t>Square</w:t></w:r></w:p><w:sectPr>{square}</w:sectPr>',
}
for name, body in bodies.items():
parts = {
"[Content_Types].xml": '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>',
"_rels/.rels": '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>',
"word/document.xml": f'<w:document {w}><w:body>{body}</w:body></w:document>',
}
with zipfile.ZipFile(f"{sys.argv[1]}/{name}", "w") as archive:
for part, text in parts.items():
archive.writestr(part, text)
PY
cp "$work/a5.docx" "$work/a5.orig"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
seen() {
for _ in $(seq 80); do "$work/ax-values" "$pid" | grep -q -- "$1" && return 0; sleep 0.25; done
return 1
}
# How often $1 has opened so far.
opened() { local n; n=$(grep -c "opened $1" "$data/ely.log" 2>/dev/null || true); echo "${n:-0}"; }
# Opens $1; extra environment in the rest.
start() {
local file=$1 before
shift
before=$(opened "$file")
env "$@" ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" perl -e 'alarm 200; exec @ARGV' \
"$root/target/debug/ely-doc" "$work/$file" >"$work/out" 2>&1 &
pid=$!
for _ in $(seq 150); do [ "$(opened "$file")" -gt "$before" ] && break; sleep 0.2; done
[ "$(opened "$file")" -gt "$before" ] || fail "$file did not open"
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
}
stop() {
pkill -9 -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
pid=
}
print() {
keys 'keystroke "p" using command down'
seen "title=Print…" || fail "no print dialog"
"$work/ax-press" "$pid" "Print…" AXButton >/dev/null
}
# The panel, cancelled: nothing changes.
start a5.docx
print
for _ in $(seq 80); do grep -q "print: paper" "$data/ely.log" && break; sleep 0.25; done
grep -q "print: paper" "$data/ely.log" || fail "the print panel did not open"
sleep 1
"$work/ax-press" "$pid" Cancel AXButton >/dev/null
for _ in $(seq 40); do grep -q "print done, printed false" "$data/ely.log" && break; sleep 0.25; done
grep -q "print done, printed false" "$data/ely.log" || fail "the cancel was not seen"
[ -z "$(find "$data/work" -name print.pdf)" ] || fail "the print copy stays"
cmp -s "$work/a5.docx" "$work/a5.orig" || fail "printing changed the file"
keys 'keystroke "w" using command down'
for _ in $(seq 40); do grep -q "office: closed a5.docx" "$data/ely.log" && break; sleep 0.25; done
grep -q "office: closed a5.docx" "$data/ely.log" || fail "the printed document asks or stays"
stop
# The real job, saved: its pages and text positions.
printed() {
rm -f "$work/printed.pdf"
# A shared half scale the print must undo.
start "$1" ELY_PRINT_TO="$work/printed.pdf" ELY_PRINT_SHARED_SCALE=0.5
print
for _ in $(seq 80); do [ -s "$work/printed.pdf" ] && break; sleep 0.25; done
[ -s "$work/printed.pdf" ] || fail "$1: no printed output"
stop
}
sizes() { pdfinfo -f 1 -l 9 "$work/printed.pdf" | awk '/^Page +[0-9]+ size:/ { printf "%.0fx%.0f ", $4, $6 }'; }
# The printed pages are $1 (sizes), each within a point.
pages_are() {
python3 -c 'import sys; got, want = sys.argv[1].split(), sys.argv[2].split()
ok = len(got) == len(want) and all(abs(float(a) - float(b)) <= 1 for g, w in zip(got, want) for a, b in zip(g.split("x"), w.split("x")))
sys.exit(0 if ok else 1)' "$(sizes)" "$1"
}
word() { pdftotext -bbox "$work/printed.pdf" - | grep -m 1 ">$1<"; }
printed a5.docx
pages_are "595x420" || fail "a5: printed on $(sizes), not 595x420"
# One inch in: no scale and no shift.
x=$(word Print | sed -E 's/.*xMin="([0-9.]+)".*/\1/')
python3 -c 'import sys; sys.exit(0 if abs(float(sys.argv[1]) - 72) < 2 else 1)' "$x" || fail "a5: the text starts at $x, not 72"
printed mixed.docx
pages_are "1191x842 1191x842" || fail "mixed: printed on $(sizes), not the A3 paper twice"
word Small >/dev/null || fail "mixed: the A5 page lost its text"
edge=$(word Edge | sed -E 's/.*xMax="([0-9.]+)".*/\1/')
python3 -c 'import sys; sys.exit(0 if 1000 < float(sys.argv[1]) <= 1191 else 1)' "$edge" || fail "mixed: the A3 edge text is at $edge"
printed crossed.docx
pages_are "1000x600 1000x600" || fail "crossed: printed on $(sizes), not 1000x600 twice"
word Begin >/dev/null && word Square >/dev/null || fail "crossed: a page lost its text"
right=$(word Right | sed -E 's/.*xMax="([0-9.]+)".*/\1/')
python3 -c 'import sys; sys.exit(0 if 900 < float(sys.argv[1]) <= 1000 else 1)' "$right" || fail "crossed: the right end is at $right"
echo "ok: print"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Logs and the diagnostics bundle keep document text out (§18.2).
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
# Keeps the run's own status; the app's SIGTERM is expected.
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
rm -rf "$work"
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/axv" "$root/tests/scripts/ax-values.swift"
swiftc -O -o "$work/press" "$root/tests/scripts/ax-press.swift"
[ "$("$work/axv" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
mkdir -p "$home/Documents"
ELY_SMOKE=hold ELY_LOCALE=en ELY_LOG=debug HOME="$home" \
perl -e 'alarm 240; exec @ARGV' "$root/target/debug/ely-doc" >"$work/out" 2>"$work/err" &
pid=$!
log="$home/Library/Application Support/ElyOffice/ely-doc/ely.log"
for _ in $(seq 200); do grep -q "doc: opened" "$log" 2>/dev/null && break; sleep 0.2; done
grep -q "doc: opened Untitled 1" "$log" || fail "no blank tab: $(tail -n 3 "$work/err")"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
press() {
for _ in $(seq 40); do
[ "$("$work/press" "$pid" "$@")" = pressed ] && { sleep 0.8; return 0; }
sleep 0.25
done
fail "could not press $*"
}
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 0.5
marker=ZqxPrivate42
keys "keystroke \"$marker typed\""
keys 'key code 36'
sleep 2
grep -q "ely_lok::worker\]" "$log" || fail "the worker's log does not reach the app log"
! grep -q "$marker" "$log" || fail "the log holds typed text: $(grep -o ".\{0,60\}$marker" "$log" | head -n 1)"
# The bundle: parts shown, home hidden, no text.
keys 'keystroke "," using command down'
press Diagnostics
for _ in $(seq 40); do "$work/axv" "$pid" | grep -q "This app's log" && break; sleep 0.25; done
"$work/axv" "$pid" | grep -q "This app's log" || fail "the parts are not shown before export"
press "Export…" AXButton
sleep 3
press Save AXButton
bundle="$home/Documents/ElyDoc-diagnostics.txt"
for _ in $(seq 40); do [ -s "$bundle" ] && break; sleep 0.25; done
[ -s "$bundle" ] || fail "no bundle written"
grep -q "ely_lok::worker\]" "$bundle" || fail "the bundle lacks the worker's lines"
! grep -q "$home" "$bundle" || fail "the bundle shows the home folder"
! grep -q "$marker" "$bundle" || fail "the bundle holds typed text"
echo "ok: privacy"
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# ElyDoc recovery: points while editing, copies after crashes.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
# Keeps the run's own status; the app's SIGTERM is expected.
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
pkill -9 -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
[ "$status" = 0 ] && rm -rf "$work" || echo "kept $work" >&2
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/ax-values" "$root/tests/scripts/ax-values.swift"
swiftc -O -o "$work/ax-press" "$root/tests/scripts/ax-press.swift"
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
data="$home/Library/Application Support/ElyOffice/ely-doc"
mkdir -p "$home"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
# Opened tabs so far; the log may not exist yet.
opened() { { grep -c "doc: opened" "$data/ely.log" 2>/dev/null || true; } | head -n 1; }
start() {
local before
before=$(opened)
ELY_SMOKE=hold ELY_LOCALE=en HOME="$home" perl -e 'alarm 200; exec @ARGV' "$root/target/debug/ely-doc" >"$work/out" 2>&1 &
pid=$!
for _ in $(seq 150); do n=$(opened); [ "${n:-0}" -gt "${before:-0}" ] && break; sleep 0.2; done
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
}
gone() {
for _ in $(seq 80); do "$work/ax-values" "$pid" | grep -q -- "$1" || return 0; sleep 0.25; done
return 1
}
seen() {
for _ in $(seq 80); do "$work/ax-values" "$pid" | grep -q -- "$1" && return 0; sleep 0.25; done
return 1
}
start
keys 'keystroke "ZqxRecover typed"'
# A point within the ten-second beat, sealed or not.
for _ in $(seq 60); do find "$data/recovery" -path '*/points/*' -name '*.bin' 2>/dev/null | grep -q . && break; sleep 0.5; done
find "$data/recovery" -path '*/points/*' -name '*.bin' | grep -q . || fail "no recovery point while editing"
# A crash: no quit, no clean mark.
kill -9 "$pid"
wait "$pid" 2>/dev/null || true
# A refused index write is told, then caught up.
index="$data/recovery/index.json"
chmod 555 "$data/recovery"
written=$(stat -f %m "$index")
start
grep -q "recovery: the last run ended Crashed" "$data/ely.log" || fail "the crash was not noticed"
seen "AXTextArea value=ZqxRecover typed" || fail "the recovered text is not back"
seen "Recovery points cannot be listed" || fail "a refused index write is not told"
chmod 755 "$data/recovery"
for _ in $(seq 60); do [ "$(stat -f %m "$index")" != "$written" ] && break; sleep 0.5; done
[ "$(stat -f %m "$index")" != "$written" ] || fail "the index never caught up"
# A second crash before the copy writes its own point.
pkill -9 -P "$pid" 2>/dev/null || true
kill -9 "$pid"
wait "$pid" 2>/dev/null || true
start
seen "AXTextArea value=ZqxRecover typed" || fail "a second crash lost the text"
# The engine ends before a new point; the old counts.
pkill -9 -P "$pid" -f ely-lok-worker || fail "no engine worker to stop"
seen "The document engine stopped" || fail "the ended engine is not told"
"$work/ax-values" "$pid" | grep -q "none yet" && fail "the taken-over point is not the last recovery point"
"$work/ax-press" "$pid" "Reopen documents" >/dev/null
gone "The document engine stopped" || fail "the engine does not come back the first time"
seen "AXTextArea value=ZqxRecover typed" || fail "the text is not back after the first engine restart"
# Unsaved: closing asks.
keys 'keystroke "w" using command down'
seen "Don't Save" || fail "the recovered copy closes without asking"
"$work/ax-press" "$pid" "Cancel" >/dev/null
# The engine alone ends: the window says so and reopens.
touch "$work/mark"
keys 'keystroke " engine"'
for _ in $(seq 60); do
find "$data/recovery" -path '*/points/*' -name '*.bin' -newer "$work/mark" | grep -q . && break
sleep 0.5
done
find "$data/recovery" -path '*/points/*' -name '*.bin' -newer "$work/mark" | grep -q . || fail "no point for the new text"
pkill -9 -P "$pid" -f ely-lok-worker || fail "no engine worker to stop"
seen "The document engine stopped" || fail "the ended engine is not told"
"$work/ax-press" "$pid" "Reopen documents" >/dev/null
gone "The document engine stopped" || fail "the engine does not come back"
grep -q "the engine is back, 0 tab(s) lost" "$data/ely.log" || fail "a tab is lost"
seen "AXTextArea value=.*engine" || fail "the text is not back after the engine restarts"
keys 'keystroke "w" using command down'
seen "Don't Save" || fail "the reopened copy closes without asking"
echo "ok: recovery"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Every pin in fetch-references.sh matches its checkout.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
script=$root/scripts/fetch-references.sh
names="Ely-GPUI-Components zed libreoffice-core mupdf tesseract mailcore2 libetpan cef cef-rs milkdown codemirror-dev marktext colamd magpie agent-client-protocol omarchy"
fail() {
echo "FAIL: $*" >&2
exit 1
}
pin_of() { awk -v n="$1" '$1 == "fetch" && $2 == n { print $4 }' "$script"; }
set -- $names
[ "$(grep -cE '^fetch ' "$script")" -eq "$#" ] || fail "script pins $(grep -cE '^fetch ' "$script") sources, test names $#"
for name in $names; do
pin=$(pin_of "$name")
[ "${#pin}" -eq 40 ] || fail "$name: pin '$pin' is not a full commit"
head=$(git -C "$root/reference/$name" rev-parse HEAD 2>&1) || fail "$name: no checkout: $head"
[ "$head" = "$pin" ] || fail "$name: HEAD $head, pin $pin"
done
for header in \
libreoffice-core/include/LibreOfficeKit/LibreOfficeKit.h \
mupdf/include/mupdf/fitz.h \
mupdf/thirdparty/freetype/include/freetype/freetype.h \
tesseract/include/tesseract/capi.h \
mailcore2/CMakeLists.txt \
libetpan/src/low-level/gmail/mailgmail.h \
libetpan/src/low-level/activesync/mailactivesync.h \
cef-rs/Cargo.toml; do
[ -s "$root/reference/$header" ] || fail "missing reference/$header"
done
want=$(printf '%s\n' desktop/inc desktop/source/lib include/LibreOfficeKit libreofficekit officecfg/registry/data/org/openoffice/Office/UI | sort)
have=$(git -C "$root/reference/libreoffice-core" sparse-checkout list | sort)
[ "$have" = "$want" ] || fail "libreoffice-core sparse set differs: $have"
for name in mupdf mailcore2; do
status=$(git -C "$root/reference/$name" submodule status --recursive)
[ -n "$status" ] || fail "$name lists no submodules"
bad=$(grep '^[-+U]' <<<"$status" || true)
[ -z "$bad" ] || fail "$name submodules off their pins: $bad"
done
grep -q "rev = \"$(pin_of zed)\"" "$root/reference/Ely-GPUI-Components/Cargo.toml" ||
fail "zed pin differs from the gpui rev in Ely-GPUI-Components"
echo "references: $# pins match, sparse set and submodules in place, gpui rev agrees"
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# First-batch bundles open Finder's files on their own engines.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || {
echo "SKIP release-macos: macOS only"
exit 0
}
# Real path: the apps log theirs.
dist=$(cd "${ELY_DIST:-$root/dist}" && pwd -P)
work=$(mktemp -d)
home=$work/home
trap 'pkill -f "$dist/Ely[A-Za-z]*.app/Contents/MacOS/" 2>/dev/null || true; sleep 1; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
apps=$(awk -F'\t' '$4 == 1 { print $1 "\t" $2 }' "$root/packaging/apps.tsv")
[ "$(echo "$apps" | wc -l | tr -d ' ')" = 6 ] || fail "the first batch is not six apps"
while IFS=$'\t' read -r slug name; do
[ -d "$dist/$name.app" ] || fail "no $dist/$name.app; run packaging/macos/release.sh"
done <<<"$apps"
version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
source=$dist/ElyOffice-$version-source.tar.gz
[ -s "$source" ] || fail "no $source; run packaging/macos/release.sh"
listed=$(tar -tzf "$source")
for part in LICENSE Cargo.lock patches/libreoffice/ patches/mupdf/ scripts/fetch-references.sh scripts/build-git.sh; do
grep -qx "ElyOffice-$version/$part" <<<"$listed" || fail "the source archive lacks $part"
done
for part in agents.md PRD.md tasks/; do
! grep -qx "ElyOffice-$version/$part" <<<"$listed" || fail "the source archive has the internal $part"
done
# Copyleft upstream as built: MuPDF with our patches, and Git.
upstream=$dist/ElyOffice-$version-upstream-source.tar.gz
[ -s "$upstream" ] || fail "no $upstream; run packaging/macos/release.sh"
for part in mupdf-src/COPYING git/COPYING git/GIT-VERSION-GEN; do
tar -tzf "$upstream" "$part" >/dev/null 2>&1 || fail "the upstream archive lacks $part"
done
tar -xzOf "$upstream" mupdf-src/include/mupdf/pdf/form.h | grep -q pdf_signature_set_value_said ||
fail "the MuPDF source is not the patched one"
# Samples: fixtures, so no LibreOffice app ever runs here.
files=$work/files
mkdir -p "$files/repo"
cp "$root"/tests/fixtures/office/t.* "$files/"
printf '# Bundle\n' >"$files/t.md"
for kind in docx xlsx pptx pdf md; do
[ -s "$files/t.$kind" ] || fail "no t.$kind sample"
cp "$files/t.$kind" "$files/u.$kind"
done
git -C "$files/repo" init -q
mkdir -p "$files/second" && git -C "$files/second" init -q
log_of() { echo "$home/Library/Application Support/ElyOffice/$1/ely.log"; }
waits() { # slug, text
for _ in $(seq 240); do
grep -qF "$2" "$(log_of "$1")" 2>/dev/null && return 0
sleep 0.25
done
fail "$1 never logged '$2': $(tail -n 5 "$(log_of "$1")" 2>/dev/null)"
}
while IFS=$'\t' read -r slug name; do
app=$dist/$name.app
codesign --verify --deep --strict "$app" || fail "$name's signature does not verify"
icon=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "$app/Contents/Info.plist")
[ -s "$app/Contents/Resources/$icon.icns" ] || fail "$name has no icon"
# Licences travel with the app (§21.3).
licenses=$app/Contents/Resources/Licenses
case $slug in
ely-doc | ely-table | ely-show) engines=(LibreOffice) ;;
ely-pdf) engines=(MuPDF MuPDF/fonts/urw MuPDF/fonts/sil MuPDF/fonts/noto MuPDF/fonts/droid MuPDF/fonts/han OpenSSL tessdata GreatVibes) ;;
ely-tree) engines=(Git git-lfs) ;;
ely-mark) engines=(CEF content) ;;
esac
for part in LICENSE rust-crates.txt "${engines[@]}"; do
[ -n "$(find "$licenses/$part" -type f -size +0 2>/dev/null)" ] || fail "$name lacks the licences of $part"
done
# Each font family needs a licence, not just a README.
for family in "$licenses"/MuPDF/fonts/*; do
[ -d "$family" ] || continue
[ -n "$(find "$family" -type f -size +0 \( -iname 'licen[cs]e*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'ofl*' \))" ] ||
fail "$name lacks the licence of the ${family##*/} fonts"
done
# Nothing inside registers with macOS but CEF's helpers.
extra=$(find "$app/Contents" \( -name '*.app' -o -name '*.appex' -o -name '*.mdimporter' -o -name '*.qlgenerator' \) |
grep -v "/Frameworks/$slug Helper" || true)
[ -z "$extra" ] || fail "$name carries bundles macOS registers: $extra"
# No binary may need a newer macOS than the app.
least=$(/usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" "$app/Contents/Info.plist")
while IFS= read -r binary; do
file -b "$binary" | grep -q '^Mach-O' || continue
# vtool: otool reads "name (x)" as an archive member.
minos=$(vtool -show-build "$binary" | awk '/minos/ && !v { v = $2 } END { print v }')
[ -z "$minos" ] || [ "$(printf '%s\n%s\n' "$minos" "$least" | sort -V | tail -n 1)" = "$least" ] ||
fail "${binary#"$app"/} needs macOS $minos; $name says $least"
done < <(find "$app" -type f \( -perm -111 -o -name '*.dylib' \))
case $slug in
ely-doc) first=t.docx second=u.docx ;;
ely-table) first=t.xlsx second=u.xlsx ;;
ely-show) first=t.pptx second=u.pptx ;;
ely-pdf) first=t.pdf second=u.pdf ;;
ely-mark) first=t.md second=u.md ;;
ely-tree) first=repo second=second ;;
esac
# Finder's launch: no arguments, an open request.
open -n -a "$app" --env "HOME=$home" --env ELY_LOG=info --env ELY_NO_DIALOG=1 "$files/$first"
case $slug in
ely-tree) opened() { echo "tree: opened $(cd "$files/$1" && pwd -P)"; } ;;
*) opened() { echo "$slug: opened $1 as tab"; } ;;
esac
waits "$slug" "$(opened "$first")"
log=$(log_of "$slug")
case $slug in
ely-doc | ely-table | ely-show)
grep -qF "lok: worker from $app/Contents/MacOS/ely-lok-worker" "$log" || fail "$name used another LOK worker"
grep -qF "worker from $app/Contents/MacOS/../Frameworks" "$log" ||
fail "$name used another kernel: $(grep 'worker from' "$log")"
! grep -q "opened Untitled" "$log" || fail "$name opened a blank document beside the file"
;;
ely-pdf) grep -qF "pdf: worker from $app/Contents/MacOS/ely-pdf-worker" "$log" || fail "ElyPDF used another worker" ;;
ely-tree)
grep -qF "at $app/Contents/MacOS/../Resources/git/bin/git (bundled)" "$log" || fail "ElyTree used another Git"
# Built-ins stay links to one binary, not copies.
[ "$(du -sm "$app/Contents/Resources/git" | cut -f1)" -lt 100 ] || fail "ElyTree's Git copies its built-ins"
;;
ely-mark)
grep -qF "web: CEF loaded from $app/Contents/Frameworks" "$log" || fail "ElyMark used another CEF"
grep -qF "mark: page from $app/Contents/Resources/content" "$log" || fail "ElyMark used another page"
;;
esac
# A running app takes Finder's next file.
open -a "$app" "$files/$second"
waits "$slug" "$(opened "$second")"
pkill -f "$app/Contents/MacOS/$slug" || true
echo "ok: $name"
done <<<"$apps"
# The app menu starts another installed app (§16).
opens=$(grep -c "ElyDoc window open" "$(log_of ely-doc)")
open -n -a "$dist/ElyDoc.app" --env "HOME=$home" --env ELY_LOCALE=en --env ELY_NO_DIALOG=1
for _ in $(seq 120); do [ "$(grep -c "ElyDoc window open" "$(log_of ely-doc)")" -gt "$opens" ] && break; sleep 0.25; done
doc=$(pgrep -f "$dist/ElyDoc.app/Contents/MacOS/ely-doc" | head -n 1)
[ -n "$doc" ] || fail "ElyDoc did not start"
osascript -e "tell application \"System Events\" to tell (first process whose unix id is $doc)" \
-e 'set frontmost to true' -e 'delay 1' \
-e 'click menu item "ElyMark" of menu 1 of menu item "Open Ely App" of menu 1 of menu bar item 2 of menu bar 1' \
-e 'end tell' >/dev/null || fail "ElyDoc's app menu has no ElyMark"
for _ in $(seq 40); do pgrep -f "$dist/ElyMark.app/Contents/MacOS/ely-mark" >/dev/null && break; sleep 0.25; done
pgrep -f "$dist/ElyMark.app/Contents/MacOS/ely-mark" >/dev/null || fail "the menu did not start ElyMark"
pkill -f "$dist/Ely[A-Za-z]*.app/Contents/MacOS/" || true
echo "release-macos: 6 bundles open files on their own engines and start each other"
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
# Settings: one app's change reaches another; bad input is refused.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
pids=()
cleanup() {
for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done
rm -rf "$work"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-pdf -p ely-tree --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press click window-shade; do
swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"
done
home="$work/home"
support="$home/Library/Application Support/ElyOffice"
mkdir -p "$support"
printf '{"version":1,"appearance":"light"}' >"$support/settings.json"
launch() {
ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" \
perl -e 'alarm 300; exec @ARGV' "$root/target/debug/$1" >"$work/$1.out" 2>&1 &
pids+=($!)
}
ax() { "$work/ax-values" "$1"; }
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
# Waits until $1's AX tree matches $2.
seen() {
for _ in $(seq 100); do
ax "$1" | grep -q -- "$2" && return 0
sleep 0.2
done
return 1
}
front() {
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $1) to true" >/dev/null
sleep 0.5
}
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
# Another process holds the lock file $1 for $2 seconds.
hold() {
python3 -c 'import fcntl, sys, time; f = open(sys.argv[1], "a"); fcntl.flock(f, fcntl.LOCK_EX); time.sleep(float(sys.argv[2]))' "$1" "$2" &
pids+=($!)
sleep 0.3
}
# Clicks the field AX names $2 in app $1.
field() {
local at
# A page that just opened may still be moving.
sleep 0.5
at=$(ax "$1" | grep -m 1 "^AXTextField.*title=$2" | sed 's/.*at=//')
[ -n "$at" ] || fail "no field $2"
"$work/click" "${at%,*}" "${at#*,}"
sleep 0.3
}
launch ely-pdf
pdf=${pids[${#pids[@]}-1]}
launch ely-tree
tree=${pids[${#pids[@]}-1]}
seen "$pdf" AXWindow || fail "ElyPDF opened no window"
seen "$tree" AXWindow || fail "ElyTree opened no window"
sleep 2
shade=$("$work/window-shade" "$tree")
[ "$shade" != denied ] || { echo "SKIP: needs Screen Recording permission"; exit 0; }
[ "${shade%% *}" -gt 150 ] || fail "ElyTree starts light, not at ${shade%% *}"
# ElyPDF's Dark reaches ElyTree without a restart.
front "$pdf"
keys 'keystroke "," using command down'
seen "$pdf" "title=Settings" || fail "Cmd+, opened no settings window"
"$work/ax-press" "$pdf" Dark >/dev/null
for _ in $(seq 50); do grep -q '"dark"' "$support/settings.json" && break; sleep 0.2; done
grep -q '"appearance": "dark"' "$support/settings.json" || fail "Dark is not saved: $(cat "$support/settings.json")"
for _ in $(seq 25); do
shade=$("$work/window-shade" "$tree")
[ "${shade%% *}" -lt 80 ] && break
sleep 0.2
done
[ "${shade%% *}" -lt 80 ] || fail "ElyTree did not turn dark: ${shade%% *}; $(grep -E 'settings: changed|mode ->' "$support/ely-tree/ely.log" | tail -3)"
# The author name is saved for every app.
"$work/ax-press" "$pdf" Documents >/dev/null
seen "$pdf" "title=Author name" || fail "no author field"
field "$pdf" "Author name"
keys 'keystroke "Ana Test"'
keys 'key code 36'
for _ in $(seq 50); do grep -q 'Ana Test' "$support/settings.json" && break; sleep 0.2; done
grep -q '"author": "Ana Test"' "$support/settings.json" || fail "the author is not saved"
grep -q '"appearance": "dark"' "$support/settings.json" || fail "the author write lost the appearance"
# A name from elsewhere fills the unedited field.
python3 - "$support/settings.json" <<'PY'
import json, sys
path = sys.argv[1]
fields = json.load(open(path))
fields["author"] = "Bob"
open(path, "w").write(json.dumps(fields))
PY
seen "$pdf" "value=Bob.title=Author name" || fail "the field kept the old name"
keys 'key code 48'
sleep 1
grep -q '"author": "Bob"' "$support/settings.json" || fail "leaving the field wrote the old name back"
# A failed write is shown, not only logged.
mv "$support/.settings.lock" "$support/.settings.lock.file"
mkdir "$support/.settings.lock"
field "$pdf" "Author name"
keys 'keystroke "a" using command down'
keys 'keystroke "Carol"'
keys 'key code 36'
seen "$pdf" "was not saved" || fail "the failed write is not shown"
sleep 1
seen "$pdf" "value=Carol.title=Author name" || fail "the failed name was not kept for a retry"
grep -q '"author": "Bob"' "$support/settings.json" || fail "the failed name reached the file"
rmdir "$support/.settings.lock" && mv "$support/.settings.lock.file" "$support/.settings.lock"
keys 'key code 36'
for _ in $(seq 50); do grep -q Carol "$support/settings.json" && break; sleep 0.2; done
grep -q '"author": "Carol"' "$support/settings.json" || fail "the retry did not save"
# Changing back while a save waits: last choice stays.
hold "$support/.settings.lock" 4
keys 'keystroke "a" using command down'
keys 'keystroke "Dave"'
keys 'key code 36'
sleep 0.5
keys 'keystroke "a" using command down'
keys 'keystroke "Carol"'
keys 'key code 36'
sleep 5
grep -q '"author": "Carol"' "$support/settings.json" || fail "a waiting save overtook the last choice: $(grep author "$support/settings.json")"
seen "$pdf" "value=Carol.title=Author name" || fail "the field shows a waiting save"
# ElyTree shows its own unreadable file.
mkdir -p "$support/ely-tree"
printf '{' >"$support/ely-tree/settings.json"
front "$tree"
keys 'keystroke "," using command down'
seen "$tree" "title=Settings" || fail "ElyTree opened no settings window"
"$work/ax-press" "$tree" Git >/dev/null
seen "$tree" "title=Git program" || fail "no Git field"
seen "$tree" "cannot be read" || fail "ElyTree's unreadable file is not shown"
printf '{}' >"$support/ely-tree/settings.json"
# ElyTree refuses a program that is not Git.
field "$tree" "Git program"
keys 'keystroke "/bin/echo"'
keys 'key code 36'
seen "$tree" "is not Git 2.40 or later" || fail "a non-Git program was not refused: $(ax "$tree" | grep -i "git\|echo")"
! grep -q echo "$support/ely-tree/settings.json" 2>/dev/null || fail "the refused program was saved"
# A slow check finishing last never overwrites a later choice.
cat >"$work/slow-git" <<'SH'
#!/bin/sh
sleep 4
echo "git version 2.56.0"
SH
chmod +x "$work/slow-git"
bundled="$root/engines/git/bin/git"
[ -x "$bundled" ] || fail "no bundled Git at $bundled"
field "$tree" "Git program"
keys 'keystroke "a" using command down'
keys "keystroke \"$work/slow-git\""
keys 'key code 36'
sleep 0.5
keys 'keystroke "a" using command down'
keys "keystroke \"$bundled\""
keys 'key code 36'
sleep 6
grep -q "\"git\": \"$bundled\"" "$support/ely-tree/settings.json" || fail "the slow check won: $(cat "$support/ely-tree/settings.json")"
! ax "$tree" | grep -q "is not Git" || fail "a stale refusal stayed on screen"
# Choosing the saved Git again outdates a slow check.
field "$tree" "Git program"
keys 'keystroke "a" using command down'
keys "keystroke \"$work/slow-git\""
keys 'key code 36'
sleep 0.5
keys 'keystroke "a" using command down'
keys "keystroke \"$bundled\""
keys 'key code 36'
sleep 6
grep -q "\"git\": \"$bundled\"" "$support/ely-tree/settings.json" || fail "the slow check overwrote the saved choice"
# A Git write waiting for the lock loses.
cat >"$work/fast-git" <<'SH'
#!/bin/sh
echo "git version 2.56.0"
SH
chmod +x "$work/fast-git"
hold "$support/ely-tree/.settings.lock" 4
field "$tree" "Git program"
keys 'keystroke "a" using command down'
keys "keystroke \"$work/fast-git\""
keys 'key code 36'
sleep 1
keys 'keystroke "a" using command down'
keys "keystroke \"$bundled\""
keys 'key code 36'
sleep 5
grep -q "\"git\": \"$bundled\"" "$support/ely-tree/settings.json" || fail "a waiting Git write overtook the last choice"
# An unreadable file is shown, and never overwritten.
printf '{' >"$support/settings.json"
front "$pdf"
seen "$pdf" "cannot be read" || fail "the unreadable file is not shown: $(grep settings "$support/ely-pdf/ely.log" | tail -n 3)"
"$work/ax-press" "$pdf" General >/dev/null
"$work/ax-press" "$pdf" Light >/dev/null
sleep 1
[ "$(cat "$support/settings.json")" = "{" ] || fail "the unreadable file was overwritten"
# The main window reopens where it was left.
printf '{"version":1}' >"$support/settings.json"
osascript -e "tell application \"System Events\" to set position of (first window of (first process whose unix id is $pdf) whose name is \"ElyPDF\") to {120, 140}" >/dev/null
for _ in $(seq 50); do grep -q '"x": 120' "$support/ely-pdf/settings.json" 2>/dev/null && break; sleep 0.2; done
grep -q '"x": 120' "$support/ely-pdf/settings.json" || fail "the window place is not saved: $(cat "$support/ely-pdf/settings.json" 2>/dev/null)"
kill "$pdf"
wait "$pdf" 2>/dev/null || true
launch ely-pdf
pdf=${pids[${#pids[@]}-1]}
seen "$pdf" "AXWindow.*title=ElyPDF" || fail "ElyPDF did not reopen"
place=$(osascript -e "tell application \"System Events\" to get position of (first window of (first process whose unix id is $pdf))")
[ "$place" = "120, 140" ] || fail "ElyPDF reopened at $place"
echo "ok: settings"
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# The shell gallery opens, switches pages, keeps focus, and quits.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-ui --example shell --manifest-path "$root/Cargo.toml"
out=$(ELY_SMOKE=1 HOME="$work/smoke" XDG_DATA_HOME="$work/smoke/data" perl -e 'alarm 60; exec @ARGV' "$root/target/debug/examples/shell" 2>"$work/smoke.err") ||
fail "the gallery did not exit cleanly: $(tail -n 5 "$work/smoke.err")"
[ "$out" = "ready ElyDoc" ] || fail "the gallery printed '$out', expected 'ready ElyDoc'"
run_gallery() {
ELY_SMOKE=hold ELY_GALLERY_PROBE=1 ELY_PROBE_HOLD_AT=${3:-} ELY_APPEARANCE=dark ELY_LOCALE=${4:-en} HOME="$work/$1" XDG_DATA_HOME="$work/$1/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/examples/shell" >/dev/null 2>"$work/$1.err" &
pid=$!
}
wait_line() {
for _ in $(seq $(($3 * 5))); do
grep -q -- "$2" "$(find "$work/$1" -path '*/ElyOffice/ely-doc/ely.log' 2>/dev/null | head -n 1)" 2>/dev/null && return 0
sleep 0.2
done
return 1
}
run_gallery probe
wait "$pid" || fail "the probe did not exit cleanly: $(tail -n 5 "$work/probe.err")"
log=$(find "$work/probe" -path "*/ElyOffice/ely-doc/ely.log" | head -n 1)
[ -n "$log" ] || fail "the probe wrote no log"
expect() { grep -q "probe: $1" "$log" || fail "no 'probe: $1' in the probe log"; }
expect_raw() { grep -q "$1" "$log" || fail "no '$1' in the probe log"; }
expect_re() { grep -qE "$1" "$log" || fail "no '$1' in the probe log"; }
expect "nav on page one shown=true"
expect "layout with both panels fits=true"
expect "props on page one shown=true"
expect "props on page two hidden=true"
expect "layout without right panel fits=true"
expect "content on page two shown=true"
expect "content focus kept=true"
expect "props back on page one shown=true"
expect "layout restored fits=true"
expect "menus app=ElyDoc File=New|Open…|Save|Save As…|Export…|Print…"
expect "menu map New=office::New|Open…=office::Open|Save=office::Save|Save As…=office::SaveAs|Export…=office::Export|Print…=office::Print"
expect_raw "command: file.new ran in ElyDoc"
expect "trigger keys file.new -> file.new"
expect "trigger menu file.new -> file.new"
expect "trigger keys file.open -> file.open"
expect "trigger menu file.open -> file.open"
for id in save save_as export print; do
expect "trigger keys file.$id -> file.$id blocked"
expect "trigger menu file.$id -> file.$id blocked"
done
expect "palette open=true"
expect "palette took focus=true"
expect_raw "command: file.open ran in ElyDoc"
expect "palette open=false"
expect "palette toggle-off returned focus=true"
expect "blocked save -> file.save blocked"
expect_re "toast [0-9]+: No open document"
expect "recent=file.open,file.new"
expect "tab 1 -> content"
expect "tab 2 -> props"
expect "esc closed palette=true"
expect "props rows Format=Mixed|Pages=2"
expect "rename open=true text=fail"
expect "renamed title=Appendix|Page Two"
expect "delete cancel pages=Appendix|Page Two"
expect "delete done pages=Appendix"
expect "task export -> Succeeded"
expect "task error kept -> Failed(The disk is full)"
expect "task after retry -> Succeeded"
expect "task cancel beats=1"
expect_re "chip by tab=true"
expect "layout back fits=true"
expect "resized viewport=800x600"
expect "resized layout fits=true"
expect "resized click hits=true"
expect "restored viewport=1100x720"
expect_re "scale factor=[0-9]"
expect "keyup-only enter keeps dialog=true titles=Appendix|Page Two"
expect "keyup kept text=页面1"
expect "keyup-only enter keeps palette=true titles="
expect "keyup palette closed=true"
expect "cjk text=页面1"
expect_re "cjk undo=$"
expect "delete last pages="
expect "empty rename refused=true"
expect "empty delete refused=true"
expect_re "palette during task at Running [012]/4"
run_gallery holdmix "" 49
wait_line holdmix "probe: hold at 49" 60 || fail "the gallery never held the mixed state"
"$root/tests/scripts/shot.sh" "$pid" "$work/mixed.png" || fail "no window for the mixed shot"
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
run_gallery holdrun "" running
wait_line holdrun "probe: hold at running" 60 || fail "the gallery never held the running task"
"$root/tests/scripts/shot.sh" "$pid" "$work/running.png" || fail "no window for the running shot"
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
run_gallery holdfail "" failed
wait_line holdfail "probe: hold at failed" 60 || fail "the gallery never held the failed task"
"$root/tests/scripts/shot.sh" "$pid" "$work/failed.png" || fail "no window for the failed shot"
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
for name in mixed running failed; do
diff=$(python3 "$root/tests/scripts/ink.py" cmp "$work/$name.png" "$root/tests/fixtures/shell/$name.rgbz")
if [ "$diff" -ne 0 ]; then
cp "$work/$name.png" "$root/target/shell-$name.png"
fail "the $name region differs in $diff pixels; shot kept at target/shell-$name.png"
fi
done
for locale in zh-CN fr zh-Hans; do
run_gallery "loc-$locale" "" 11 "$locale"
wait_line "loc-$locale" "probe: menus app=" 60 || fail "locale $locale: no menus log"
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
loclog() { find "$work/loc-$1" -path '*/ElyOffice/ely-doc/ely.log' | head -n 1; }
grep -q "menus app=ElyDoc File=新建|打开…|保存|另存为…|导出…|打印…" "$(loclog zh-CN)" || fail "zh-CN menus are not localized"
grep -q "i18n: locale zh-CN -> zh-CN" "$(loclog zh-CN)" || fail "zh-CN did not resolve to zh-CN"
grep -q "menus app=ElyDoc File=New|Open…|Save|Save As…|Export…|Print…" "$(loclog fr)" || fail "fr menus did not fall back to English"
grep -q "i18n: locale fr -> en-US" "$(loclog fr)" || fail "fr did not resolve to en-US"
grep -q "menus app=ElyDoc File=新建|打开…|保存|另存为…|导出…|打印…" "$(loclog zh-Hans)" || fail "zh-Hans menus are not localized"
echo "shell: regions, layout, focus, menus, commands, palette, dialogs, tasks pass"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Usage: shot.sh [--id] <pid> [out.png]; macOS only.
set -euo pipefail
[ "$(uname)" = Darwin ] || { echo "shot: needs macOS" >&2; exit 2; }
id_only=
if [ "${1:-}" = --id ]; then
id_only=1
shift
fi
pid=${1:?pid}
id=$(osascript -l JavaScript - "$pid" <<'JS'
ObjC.import('CoreGraphics');
function run(argv) {
const pid = parseInt(argv[0]);
const all = ObjC.deepUnwrap(ObjC.castRefToObject($.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly, 0)));
const mine = all.filter(w => w.kCGWindowOwnerPID === pid && w.kCGWindowLayer === 0);
return mine.length ? String(mine[0].kCGWindowNumber) : '';
}
JS
)
[ -n "$id" ] || { echo "shot: process $pid has no on-screen window" >&2; exit 1; }
if [ -n "$id_only" ]; then
echo "$id"
exit 0
fi
out=${2:?out.png}
screencapture -x -o -l"$id" "$out"
[ -s "$out" ] || { echo "shot: screencapture wrote nothing for window $id" >&2; exit 1; }
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# Storage: sizes shown; log cleared now, cache at restart.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
# Keeps the run's own status; the app's SIGTERM is expected.
cleanup() {
local status=$?
if [ -n "${pid:-}" ]; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
rm -rf "$work"
exit "$status"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-mark --manifest-path "$root/Cargo.toml"
for tool in ax-values ax-press; do swiftc -O -o "$work/$tool" "$root/tests/scripts/$tool.swift"; done
[ "$("$work/ax-values" $$ | head -n 1)" != untrusted ] || { echo "SKIP: needs Accessibility permission"; exit 0; }
home="$work/home"
data="$home/Library/Application Support/ElyOffice/ely-mark"
mkdir -p "$home"
keys() { osascript -e 'tell application "System Events"' -e "$1" -e 'end tell' >/dev/null; }
seen() {
for _ in $(seq 60); do "$work/ax-values" "$pid" | grep -q -- "$1" && return 0; sleep 0.25; done
return 1
}
start() {
ELY_SMOKE=hold ELY_LOCALE=en HOME="$home" perl -e 'alarm 400; exec @ARGV' "$root/target/debug/ely-mark" >"$work/out" 2>&1 &
pid=$!
for _ in $(seq 40); do "$work/ax-values" "$pid" | grep -q AXWindow && break; sleep 0.25; done
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $pid) to true" >/dev/null
sleep 1
}
# The KB the page shows for row $1.
kb() { "$work/ax-values" "$pid" | grep -m 1 "title=$1" | sed 's/.*help=//; s/ KB.*//'; }
# What the page should count under $1, KB rounded up.
disk_kb() { find "$1" -type f -exec stat -f %z {} + | awk '{s += $1} END {print int((s + 1023) / 1024)}'; }
quit() {
keys 'keystroke "q" using command down'
for _ in $(seq 40); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done
wait "$pid" 2>/dev/null || true
}
mkdir -p "$data/web"
# A known 2 MiB in the cache for the row.
dd if=/dev/zero of="$data/web/ely-sample" bs=1024 count=2048 2>/dev/null
# A log long enough that clearing it shows.
head -c 8192 /dev/zero | tr '\0' 'x' >"$data/ely.log"
start
keys 'keystroke "," using command down'
seen "title=Storage" || fail "no settings window"
"$work/ax-press" "$pid" Storage >/dev/null
seen "title=Web content cache" || fail "the storage page lists no cache"
shown=$(kb "Web content cache")
[ "${shown:-0}" -ge 2048 ] || fail "the cache shows ${shown:-nothing} KB, not at least 2048"
shown=$(kb "This app's log")
[ "${shown:-0}" -ge 8 ] || fail "the log shows ${shown:-nothing} KB, not at least 8"
# Each clear asks first; nothing goes before the yes.
"$work/ax-press" "$pid" Clear AXButton >/dev/null
seen "title=Delete" || fail "clearing the log did not ask"
[ "$(wc -c <"$data/ely.log")" -gt 4096 ] || fail "the log went before the yes"
"$work/ax-press" "$pid" Delete AXButton >/dev/null
sleep 1
[ "$(wc -c <"$data/ely.log")" -lt 4096 ] || fail "the log was not cleared"
"$work/ax-press" "$pid" "Clear at next start" AXButton >/dev/null
seen "title=Delete" || fail "clearing the cache did not ask"
[ ! -e "$data/.clear-web" ] || fail "the cache was marked before the yes"
"$work/ax-press" "$pid" Delete AXButton >/dev/null
seen "Clears at next start" || fail "the cache clear is not shown as pending"
[ -e "$data/ely-sample" ] || [ -d "$data/web" ] || fail "the cache went while in use"
# Ten points kept per document, once twelve were made.
"$work/ax-press" "$pid" 10 >/dev/null
for _ in $(seq 20); do grep -q '"keep": 10' "$data/settings.json" 2>/dev/null && break; sleep 0.25; done
grep -q '"keep": 10' "$data/settings.json" || fail "the recovery limit is not kept"
osascript -e "tell application \"System Events\" to click button 1 of (first window of (first process whose unix id is $pid) whose name is \"Settings\")" >/dev/null
sleep 1
# An untitled document: never autosaved, so it keeps recovery points.
keys 'keystroke "n" using command down'
for _ in $(seq 40); do grep -q "mark: shown" "$data/ely.log" && break; sleep 0.25; done
sleep 2
# Twelve points written, one edit each.
written() { grep -c "recovery: a point at" "$data/ely.log" || true; }
for n in $(seq 12); do
before=$(written)
keys "keystroke \"edit $n \""
for _ in $(seq 100); do [ "$(written)" -gt "$before" ] && break; sleep 0.25; done
[ "$(written)" -gt "$before" ] || fail "edit $n wrote no point"
done
points=$(find "$data/recovery" -path '*/points/*.bin' | wc -l | tr -d ' ')
[ "$points" -eq 10 ] || fail "$points recovery points kept, not 10"
# Two picks while the file is locked: both are kept.
keys 'keystroke "," using command down'
seen "title=Storage" || fail "no settings window after the points"
"$work/ax-press" "$pid" Storage >/dev/null
seen "title=256" || fail "no space choices"
shown=$(kb "Recovery copies")
[ "$shown" = "$(disk_kb "$data/recovery")" ] || fail "recovery copies show ${shown:-nothing} KB, not $(disk_kb "$data/recovery")"
[ "$shown" -gt 0 ] || fail "recovery copies show nothing"
python3 -c 'import fcntl, sys, time; f = open(sys.argv[1], "a"); fcntl.flock(f, fcntl.LOCK_EX); time.sleep(5)' "$data/.settings.lock" &
sleep 0.3
"$work/ax-press" "$pid" 20 >/dev/null
# Another page and back: the reread keeps the waiting pick.
"$work/ax-press" "$pid" General >/dev/null
sleep 0.5
"$work/ax-press" "$pid" Storage >/dev/null
seen "title=256" || fail "no space choices after coming back"
sleep 0.5
"$work/ax-press" "$pid" 256 >/dev/null
sleep 6
grep -q '"keep": 20' "$data/settings.json" && grep -q '"megabytes": 256' "$data/settings.json" || fail "one pick undid the other: $(grep -A3 recovery "$data/settings.json")"
# Settings closed and opened again between two locked picks.
python3 -c 'import fcntl, sys, time; f = open(sys.argv[1], "a"); fcntl.flock(f, fcntl.LOCK_EX); time.sleep(6)' "$data/.settings.lock" &
sleep 0.3
"$work/ax-press" "$pid" 50 >/dev/null
osascript -e "tell application \"System Events\" to click button 1 of (first window of (first process whose unix id is $pid) whose name is \"Settings\")" >/dev/null
sleep 0.5
keys 'keystroke "," using command down'
seen "title=Storage" || fail "no settings window the second time"
"$work/ax-press" "$pid" Storage >/dev/null
seen "title=256" || fail "no space choices the second time"
"$work/ax-press" "$pid" 32 >/dev/null
sleep 7
grep -q '"keep": 50' "$data/settings.json" && grep -q '"megabytes": 32' "$data/settings.json" || fail "reopening lost a pick: $(grep -A3 recovery "$data/settings.json")"
# A failed limit save stays told after a clear succeeds.
mv "$data/.settings.lock" "$work/lock"
mkdir "$data/.settings.lock"
"$work/ax-press" "$pid" 10 >/dev/null
seen "Not done" || fail "the failed limit save is not told"
rmdir "$data/.settings.lock"
mv "$work/lock" "$data/.settings.lock"
"$work/ax-press" "$pid" Clear AXButton >/dev/null
seen "title=Delete" || fail "clearing the log did not ask the second time"
"$work/ax-press" "$pid" Delete AXButton >/dev/null
sleep 1
"$work/ax-values" "$pid" | grep -q "Not done" || fail "a clear hid the failed limit save"
"$work/ax-press" "$pid" 20 >/dev/null
for _ in $(seq 40); do grep -q '"keep": 20' "$data/settings.json" && break; sleep 0.25; done
grep -q '"keep": 20' "$data/settings.json" || fail "the retried limit is not kept"
sleep 0.5
"$work/ax-values" "$pid" | grep -q "Not done" && fail "the failure stays after a good save"
quit
touch "$data/web/ely-marker"
start
[ ! -e "$data/web/ely-marker" ] || fail "the cache was not cleared at the start"
quit
# A failed clear stops the start; the mark stays.
mkdir -p "$data/web/locked"
touch "$data/web/locked/kept" "$data/.clear-web"
chmod 555 "$data/web/locked"
status=0
ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" perl -e 'alarm 60; exec @ARGV' "$root/target/debug/ely-mark" >"$work/out" 2>&1 || status=$?
chmod 755 "$data/web/locked"
[ "$status" = 1 ] || fail "a failed clear started anyway ($status)"
grep -q "could not be deleted" "$work/out" || fail "the failed clear is not told: $(tail -3 "$work/out")"
grep -q "web: CEF" "$work/out" && fail "the cache opened after a failed clear"
[ -e "$data/.clear-web" ] && [ -e "$data/web/locked/kept" ] || fail "the failed clear lost its mark or data"
start
[ ! -e "$data/web/locked/kept" ] || fail "the clear was not tried again"
quit
echo "ok: storage"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# ElyTable's grid follows the app's dark and light mode.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-lok --bins -p ely-table --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/shade" "$root/tests/scripts/window-shade.swift"
shade_of() {
local mode=$1 home="$work/$1"
mkdir -p "$home"
ELY_SMOKE=hold ELY_LOCALE=en ELY_APPEARANCE="$mode" HOME="$home" XDG_DATA_HOME="$home/data" \
perl -e 'alarm 120; exec @ARGV' "$root/target/debug/ely-table" >"$home/out" 2>"$home/err" &
local pid=$!
trap 'kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null || true' RETURN
local log=
for _ in $(seq 200); do
log=$(find "$home" -path '*/ElyOffice/ely-table/ely.log' 2>/dev/null | head -n 1)
[ -n "$log" ] && grep -q "table: opened" "$log" && break
sleep 0.2
done
grep -q "table: opened" "$log" || fail "$mode: no workbook: $(tail -n 3 "$home/err")"
# Tiles paint after the first frames.
sleep 3
local shade
shade=$("$work/shade" "$pid")
echo "$shade"
}
# At top level, so SKIP and FAIL end the run.
check() {
local mode=$1 seen=$2 lines
[ "$seen" != denied ] || { echo "SKIP: needs Screen Recording permission"; exit 0; }
[ "$seen" != none ] || fail "$mode: no window"
read -r shade lines <<<"$seen"
# Only kernel tiles draw grid lines.
[ "$lines" -ge 5 ] || fail "$mode: no grid lines ($lines per mille)"
}
check dark "$(shade_of dark)"
dark=$shade
check light "$(shade_of light)"
light=$shade
[ "$dark" -lt 80 ] || fail "the dark grid paints at $dark"
[ "$light" -gt 200 ] || fail "the light grid paints at $light"
echo "ok: theme (dark $dark, light $light)"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# packaging/types.tsv reaches Linux, Windows and macOS alike.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
types=$root/packaging/types.tsv
fail() {
echo "FAIL: $*" >&2
exit 1
}
count=0
for slug in $(cut -f1 "$types" | sort -u); do
desktop=$("$root/packaging/linux/appimage.sh" --desktop "$slug")
windows=$("$root/packaging/windows/types.sh" "$slug")
rows=$(awk -F'\t' -v slug="$slug" '$1 == slug' "$types")
while IFS=$'\t' read -r _ ext mime; do
grep -q "^MimeType=.*$mime;" <<<"$desktop" || fail "$slug: Linux misses $mime"
if [ "$ext" = / ]; then
grep -q 'Directory\\shell' <<<"$windows" || fail "$slug: Windows misses folders"
else
grep -q "\\\\.$ext\\\\OpenWithProgids" <<<"$windows" || fail "$slug: Windows misses .$ext"
grep -q "DeleteRegValue.*\\\\.$ext\\\\OpenWithProgids" <<<"$windows" || fail "$slug: uninstall keeps .$ext"
fi
count=$((count + 1))
done <<<"$rows"
# One macOS type per MIME type; md and markdown share.
utis=$("$root/packaging/macos/documents.sh" "$slug" | grep -c '^ <string>')
mimes=$(cut -f3 <<<"$rows" | sort -u | wc -l | tr -d ' ')
[ "$utis" -eq "$mimes" ] || fail "$slug: macOS declares $utis types, the table $mimes"
done
# Never the default: macOS ranks Alternate; Windows only lists.
! grep -q 'Classes\\\.[a-z]*" ""' <(for s in $(cut -f1 "$types" | sort -u); do "$root/packaging/windows/types.sh" "$s"; done) ||
fail "Windows sets a default handler"
echo "ok: types ($count file types on three systems)"
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# A live WebView: input, fence, failures, release, paint.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
work=$(mktemp -d)
probe=
trap '[ -n "$probe" ] && kill "$probe" 2>/dev/null; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-web-content --example webprobe --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift"
swiftc -O -o "$work/press" "$root/tests/scripts/ax-press.swift"
ELY_LOG=info ELY_LOCALE=en ELY_SMOKE=hold HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 150; exec @ARGV' "$root/target/debug/examples/webprobe" >"$work/out" 2>"$work/err" &
probe=$!
wait_for() {
for _ in $(seq 400); do
grep -qxF "$1" "$work/out" && return 0
kill -0 "$probe" 2>/dev/null || break
sleep 0.25
done
fail "never saw '$1': $(cat "$work/out") $(tail -n 5 "$work/err")"
}
wait_for "probe: hold"
[ "$("$work/press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
# The page's magenta marker: share of the window.
marker() {
local share
share=$("$work/share" "$probe" 255,0,170)
case $share in
denied) fail "needs Screen Recording permission" ;;
none) fail "the probe has no window" ;;
esac
echo "$share"
}
share=$(marker)
awk -v s="$share" 'BEGIN { exit !(s > 0.01) }' || fail "the page is not painted (marker share $share)"
# A killed renderer: the fault shows, its button reloads.
pkill -P "$probe" -f -- "--type=renderer" || fail "no renderer to stop"
wait_for "probe: main failed"
sleep 1
gone=$(marker)
awk -v s="$gone" 'BEGIN { exit !(s < 0.001) }' || fail "the dead page still shows (marker share $gone)"
pressed=
for _ in $(seq 20); do
[ "$("$work/press" "$probe" Reload)" = pressed ] && pressed=1 && break
sleep 0.25
done
[ -n "$pressed" ] || fail "no Reload button in the fault view"
wait_for "probe: recovered"
sleep 1.5
back=$(marker)
awk -v s="$back" 'BEGIN { exit !(s > 0.01) }' || fail "the page did not come back (marker share $back)"
wait "$probe" || fail "the probe did not exit cleanly: $(tail -n 5 "$work/err")"
probe=
out=$(cat "$work/out")
for want in "probe: value=你好a" "probe: focused=text" "probe: zoom=1.25" "probe: stayed" \
"probe: outside=false" "probe: network=refused" "probe: udp=silent" "probe: rtc=undefined" "probe: main failed" "probe: hash loaded" "probe: missing failed" \
"probe: closed 20"; do
grep -qxF "$want" <<<"$out" || fail "expected '$want', got: $out"
done
grep -qE '^probe: fg=rgba\([0-9]+, [0-9]+, [0-9]+, ' <<<"$out" || fail "no theme token: $out"
freed=$(grep -c "a page's memory is freed" "$work/err" || true)
[ "$freed" -ge 20 ] || fail "only $freed of 20 closed pages freed their memory"
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
echo "ok: webview (marker share $share, $freed pages freed)"
+44
View File
@@ -0,0 +1,44 @@
// A window middle's mean shade and grid-line share.
import CoreGraphics
import Foundation
import ImageIO
let pid = Int32(CommandLine.arguments[1])!
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as! [[String: Any]]
guard let window = windows.first(where: {
($0[kCGWindowOwnerPID as String] as? Int32) == pid && ($0[kCGWindowLayer as String] as? Int) == 0
}) else {
print("none")
exit(0)
}
let id = window[kCGWindowNumber as String] as! Int
let file = NSTemporaryDirectory() + "ely-shade-\(pid).png"
let shot = Process()
shot.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
shot.arguments = ["-x", "-o", "-l", "\(id)", file]
try shot.run()
shot.waitUntilExit()
guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: file) as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
print("denied")
exit(0)
}
let (width, height) = (image.width, image.height)
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let context = CGContext(
data: &pixels, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
// Mean shade, and the share off the common pixel.
var shades = [Int]()
for y in (height * 2 / 5)..<(height * 7 / 10) {
for x in (width * 3 / 10)..<(width * 7 / 10) {
let at = (y * width + x) * 4
shades.append((Int(pixels[at]) + Int(pixels[at + 1]) + Int(pixels[at + 2])) / 3)
}
}
var tally = [Int](repeating: 0, count: 256)
for shade in shades { tally[shade] += 1 }
let common = tally.firstIndex(of: tally.max()!)!
let lines = shades.filter { abs($0 - common) > 12 }.count * 1000 / shades.count
print("\(shades.reduce(0, +) / shades.count) \(lines)")
+39
View File
@@ -0,0 +1,39 @@
// The share of a window's pixels near one colour.
import CoreGraphics
import Foundation
import ImageIO
let args = CommandLine.arguments
let pid = Int32(args[1])!
let want = args[2].split(separator: ",").map { Int($0)! }
let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as! [[String: Any]]
guard let window = windows.first(where: {
($0[kCGWindowOwnerPID as String] as? Int32) == pid && ($0[kCGWindowLayer as String] as? Int) == 0
}) else {
print("none")
exit(0)
}
let id = window[kCGWindowNumber as String] as! Int
let file = NSTemporaryDirectory() + "ely-share-\(pid).png"
let shot = Process()
shot.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
shot.arguments = ["-x", "-o", "-l", "\(id)", file]
try shot.run()
shot.waitUntilExit()
guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: file) as CFURL, nil),
let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
print("denied")
exit(0)
}
let (width, height) = (image.width, image.height)
var pixels = [UInt8](repeating: 0, count: width * height * 4)
let context = CGContext(
data: &pixels, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
var near = 0
for at in stride(from: 0, to: pixels.count, by: 4) {
let off = abs(Int(pixels[at]) - want[0]) + abs(Int(pixels[at + 1]) - want[1]) + abs(Int(pixels[at + 2]) - want[2])
if off < 40 { near += 1 }
}
print(String(format: "%.4f", Double(near) / Double(width * height)))