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
# 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)))