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:
817 files changed
+143101
No files matched your search
Executable
+29
@@ -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"
|
||||
Executable
+26
@@ -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"
|
||||
Executable
+68
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Executable
+37
@@ -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"
|
||||
Executable
+32
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
Executable
+25
@@ -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"
|
||||
Executable
+77
@@ -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")
|
||||
Executable
+29
@@ -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"
|
||||
Executable
+58
@@ -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"
|
||||
Executable
+39
@@ -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"
|
||||
Executable
+37
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Executable
+36
@@ -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)"
|
||||
Executable
+87
@@ -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"
|
||||
@@ -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()
|
||||
Executable
+47
@@ -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"
|
||||
Executable
+43
@@ -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"; }
|
||||
Executable
+86
@@ -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)"
|
||||
Executable
+46
@@ -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"
|
||||
Executable
+51
@@ -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"
|
||||
Executable
+119
@@ -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)"
|
||||
Executable
+179
@@ -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\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)"
|
||||
Executable
+219
@@ -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.' '' '' '' "<img src='assets/pic.png'>" '' \
|
||||
"<img alt='a > b' src='assets/pic.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$' '' '' '' '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"
|
||||
Executable
+344
@@ -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"
|
||||
Executable
+188
@@ -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"
|
||||
Executable
+166
@@ -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"
|
||||
Executable
+223
@@ -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"
|
||||
Executable
+44
@@ -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[*]}"
|
||||
@@ -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)
|
||||
Executable
+128
@@ -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"
|
||||
Executable
+15
@@ -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"
|
||||
Executable
+77
@@ -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"
|
||||
Executable
+145
@@ -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"
|
||||
Executable
+67
@@ -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"
|
||||
Executable
+104
@@ -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"
|
||||
Executable
+51
@@ -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"
|
||||
Executable
+151
@@ -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"
|
||||
Executable
+230
@@ -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"
|
||||
Executable
+129
@@ -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"
|
||||
Executable
+30
@@ -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; }
|
||||
Executable
+167
@@ -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"
|
||||
Executable
+51
@@ -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)"
|
||||
Executable
+34
@@ -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)"
|
||||
Executable
+75
@@ -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)"
|
||||
@@ -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)")
|
||||
@@ -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)))
|
||||
Reference in new issue
Block a user