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
|
||||
# Pinned libetpan has every header MailCore 2 includes.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
prefix=$root/engines/libetpan
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"$root/scripts/build-libetpan.sh" >"$work/build.log" 2>&1 || {
|
||||
cat "$work/build.log" >&2
|
||||
fail "build failed"
|
||||
}
|
||||
[ -s "$prefix/lib/libetpan.a" ] || fail "no libetpan.a"
|
||||
config=$prefix/BUILD-config.h
|
||||
grep -q '^#define HAVE_OPENSSL 1' "$config" || fail "libetpan built without TLS"
|
||||
grep -q '^#define HAVE_JSON 1' "$config" || fail "libetpan built without json-c"
|
||||
|
||||
headers=$(grep -rhoE '#[[:space:]]*include[[:space:]]*<libetpan/[A-Za-z_0-9]+\.h>' "$root/reference/mailcore2/src" |
|
||||
sed -E 's/.*<(libetpan\/[^>]+)>.*/\1/' | sort -u)
|
||||
[ -n "$headers" ] || fail "MailCore 2 sources include no libetpan header"
|
||||
for header in $headers; do
|
||||
[ -f "$prefix/include/$header" ] || fail "pinned libetpan lacks $header"
|
||||
done
|
||||
echo "libetpan: $(wc -l <<<"$headers" | tr -d ' ') headers MailCore 2 includes are present"
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Probes LOK on macOS: load, export, tile painting, thread rule.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
here=$root/tests/engines
|
||||
expect=${ELY_LOK_EXPECT_TILE:-untouched}
|
||||
frameworks=${ELY_LOK_DIR:-}
|
||||
work=$(mktemp -d)
|
||||
mount=
|
||||
cleanup() {
|
||||
if [ -n "$mount" ]; then
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if hdiutil detach "$mount" -quiet 2>/dev/null; then
|
||||
mount=
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[ -z "$mount" ] || {
|
||||
echo "FAIL: cannot detach $mount; left in place" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
run() { SAL_FORCE_HIDPI_SCALING=1 SAL_SKIA=raster perl -e 'alarm 180; exec @ARGV' "$@"; }
|
||||
|
||||
case $expect in written | untouched) ;; *) fail "ELY_LOK_EXPECT_TILE is written or untouched, not $expect" ;; esac
|
||||
[ "$(uname)" = Darwin ] || {
|
||||
echo "SKIP lok: macOS only"
|
||||
exit 0
|
||||
}
|
||||
if [ -z "$frameworks" ]; then
|
||||
dmg=$(ls "$root"/engines/LibreOffice_*_MacOS_aarch64.dmg 2>/dev/null | head -n 1 || true)
|
||||
[ -n "$dmg" ] || {
|
||||
echo "SKIP lok: set ELY_LOK_DIR, or put the LibreOffice dmg in engines/"
|
||||
exit 0
|
||||
}
|
||||
[ "$(stat -f %z "$dmg")" = 297798926 ] || fail "$dmg is not the pinned 26.2.6 aarch64 image"
|
||||
mount=$work/mnt
|
||||
mkdir "$mount"
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg" >/dev/null
|
||||
frameworks=$mount/LibreOffice.app/Contents/Frameworks
|
||||
fi
|
||||
|
||||
exports=$(nm -gU "$frameworks/libmergedlo.dylib")
|
||||
for symbol in _libreofficekit_hook _libreofficekit_hook_2 _lok_preinit; do
|
||||
grep -qE " T $symbol\$" <<<"$exports" || fail "libmergedlo does not export $symbol"
|
||||
done
|
||||
plugins=$(ls "$frameworks" | grep '^libvclplug_' || true)
|
||||
[ "$plugins" = libvclplug_osxlo.dylib ] || fail "VCL plugins are not only osx: $plugins"
|
||||
|
||||
python3 - "$work/hello.docx" <<'PY'
|
||||
import sys, zipfile
|
||||
types = '<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'
|
||||
rels = '<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'
|
||||
body = '<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:b/><w:sz w:val="48"/></w:rPr><w:t>Ely Office 合同 Contract</w:t></w:r></w:p><w:p><w:r><w:t>The quick brown fox jumps over the lazy dog.</w:t></w:r></w:p></w:body></w:document>'
|
||||
with zipfile.ZipFile(sys.argv[1], "w", zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr("[Content_Types].xml", types)
|
||||
z.writestr("_rels/.rels", rels)
|
||||
z.writestr("word/document.xml", body)
|
||||
PY
|
||||
clang -O1 -I "$root/reference/libreoffice-core/include" "$here/lok_probe.c" -o "$work/probe" || fail "probe does not compile"
|
||||
args=("$frameworks" "file://$work/profile" "file://$work/hello.docx" "file://$work/out.pdf")
|
||||
|
||||
out=$(run "$work/probe" "${args[@]}" unipoll 2>"$work/err") || fail "unipoll probe: $(tail -n 5 "$work/err")"
|
||||
grep -q '"BuildId": "8221e31b3ac356a1623c672912a3d2b492f7e3d1"' <<<"$out" || fail "not the pinned build: $out"
|
||||
grep -qE '^twips=[1-9][0-9]*x[1-9][0-9]*$' <<<"$out" || fail "document has no size: $out"
|
||||
grep -qx 'pdf=1' <<<"$out" || fail "PDF export: $out"
|
||||
[ -s "$work/out.pdf" ] || fail "no PDF written"
|
||||
grep -qx 'polls=0' <<<"$out" || fail "Aqua called the poll callback: $out"
|
||||
case $expect in
|
||||
untouched) grep -qx 'painted=0' <<<"$out" || fail "tile painted, expected untouched: $out" ;;
|
||||
written)
|
||||
grep -qE '^painted=[1-9]' <<<"$out" || fail "tile untouched, expected painted: $out"
|
||||
grep -qE '^ink=[1-9]' <<<"$out" || fail "tile has no text pixels: $out"
|
||||
;;
|
||||
esac
|
||||
|
||||
if run "$work/probe" "${args[@]}" threaded >/dev/null 2>"$work/err"; then fail "threaded mode did not crash"; fi
|
||||
grep -q 'NSWindow should only be instantiated on the main thread' "$work/err" || fail "threaded crash has another cause: $(head -n 3 "$work/err")"
|
||||
echo "lok: load, PDF export, thread rule pass; tile $expect"
|
||||
@@ -0,0 +1,79 @@
|
||||
#define LOK_USE_UNSTABLE_API
|
||||
#include <LibreOfficeKit/LibreOfficeKitInit.h>
|
||||
#include <dispatch/dispatch.h>
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define PX 256
|
||||
#define SENTINEL 0xAB
|
||||
|
||||
static LibreOfficeKit *lok;
|
||||
static const char *doc_url, *pdf_url;
|
||||
static volatile int polls;
|
||||
|
||||
static void probe(void) {
|
||||
printf("version=%s\n", lok->pClass->getVersionInfo(lok));
|
||||
LibreOfficeKitDocument *doc = lok->pClass->documentLoad(lok, doc_url);
|
||||
if (!doc) {
|
||||
printf("load=fail %s\n", lok->pClass->getError(lok));
|
||||
exit(1);
|
||||
}
|
||||
doc->pClass->initializeForRendering(doc, NULL);
|
||||
long w = 0, h = 0;
|
||||
doc->pClass->getDocumentSize(doc, &w, &h);
|
||||
printf("twips=%ldx%ld\npolls=%d\n", w, h, polls);
|
||||
|
||||
unsigned char *tile = malloc(PX * PX * 4);
|
||||
memset(tile, SENTINEL, PX * PX * 4);
|
||||
doc->pClass->paintTile(doc, tile, PX, PX, 0, 0, PX * 15, PX * 15);
|
||||
long painted = 0, ink = 0;
|
||||
for (long i = 0; i < PX * PX; i++) {
|
||||
unsigned char *p = tile + i * 4;
|
||||
if (p[0] != SENTINEL || p[1] != SENTINEL || p[2] != SENTINEL || p[3] != SENTINEL) painted++;
|
||||
if (p[3] > 0 && p[0] < 128 && p[1] < 128 && p[2] < 128) ink++;
|
||||
}
|
||||
printf("painted=%ld\nink=%ld\npdf=%d\n", painted, ink, doc->pClass->saveAs(doc, pdf_url, "pdf", NULL));
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static int poll_cb(void *data, int timeout_us) {
|
||||
polls++;
|
||||
return 0;
|
||||
}
|
||||
static void wake_cb(void *data) {}
|
||||
|
||||
static void *post_probe(void *arg) {
|
||||
sleep(1);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
probe();
|
||||
_exit(0);
|
||||
});
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 6) {
|
||||
fprintf(stderr, "usage: install profile-url doc-url pdf-url unipoll|threaded\n");
|
||||
return 2;
|
||||
}
|
||||
doc_url = argv[3];
|
||||
pdf_url = argv[4];
|
||||
int unipoll = strcmp(argv[5], "unipoll") == 0;
|
||||
if (unipoll) setenv("SAL_LOK_OPTIONS", "unipoll", 1);
|
||||
lok = lok_init_2(argv[1], argv[2]);
|
||||
if (!lok) {
|
||||
fprintf(stderr, "lok_init_2 failed\n");
|
||||
return 1;
|
||||
}
|
||||
if (!unipoll) {
|
||||
probe();
|
||||
return 0;
|
||||
}
|
||||
pthread_t thread;
|
||||
pthread_create(&thread, NULL, post_probe, NULL);
|
||||
lok->pClass->runLoop(lok, poll_cb, wake_cb, NULL);
|
||||
return 0;
|
||||
}
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds MuPDF, renders a PDF page, extracts its text.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
out=$root/engines/mupdf-build
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"$root/scripts/build-mupdf.sh" >"$work/build.log" 2>&1 || fail "build failed, see $work/build.log"
|
||||
python3 - "$work/hello.pdf" <<'PY'
|
||||
import sys
|
||||
stream = b"BT /F1 24 Tf 72 700 Td (Ely Office contract) Tj ET"
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length %d >>\nstream\n%s\nendstream" % (len(stream), stream),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
body, offsets = b"%PDF-1.4\n", []
|
||||
for n, obj in enumerate(objs, 1):
|
||||
offsets.append(len(body))
|
||||
body += b"%d 0 obj\n%s\nendobj\n" % (n, obj)
|
||||
xref = len(body)
|
||||
body += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
|
||||
body += b"".join(b"%010d 00000 n \n" % o for o in offsets)
|
||||
body += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
|
||||
open(sys.argv[1], "wb").write(body)
|
||||
PY
|
||||
clang -O1 -I "$root/reference/mupdf/include" "$(dirname "$0")/mupdf_probe.c" \
|
||||
"$out/libmupdf.a" "$out/libmupdf-third.a" -lm -o "$work/probe" || fail "probe does not link"
|
||||
result=$("$work/probe" "$work/hello.pdf") || fail "probe failed: $result"
|
||||
|
||||
grep -qx 'pages=1' <<<"$result" || fail "page count: $result"
|
||||
grep -qx 'bounds=612x792' <<<"$result" || fail "page bounds: $result"
|
||||
grep -qx 'pixmap=1224x1584' <<<"$result" || fail "pixmap size: $result"
|
||||
grep -qE '^dark=[1-9][0-9]*$' <<<"$result" || fail "page renders blank: $result"
|
||||
grep -qx 'text=Ely Office contract' <<<"$result" || fail "text extraction: $result"
|
||||
nm -u "$out/libmupdf-pkcs7.a" 2>/dev/null | grep -q 'PKCS7_' || fail "signing helper is a stub: no OpenSSL PKCS7 symbols"
|
||||
echo "mupdf: render, text and OpenSSL signing helper pass"
|
||||
@@ -0,0 +1,30 @@
|
||||
#include <mupdf/fitz.h>
|
||||
#include <stdio.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
fz_context *ctx = fz_new_context(NULL, NULL, FZ_STORE_DEFAULT);
|
||||
if (argc != 2 || !ctx) return 2;
|
||||
fz_register_document_handlers(ctx);
|
||||
fz_try(ctx) {
|
||||
fz_document *doc = fz_open_document(ctx, argv[1]);
|
||||
fz_page *page = fz_load_page(ctx, doc, 0);
|
||||
fz_rect box = fz_bound_page(ctx, page);
|
||||
fz_pixmap *pix = fz_new_pixmap_from_page(ctx, page, fz_scale(2, 2), fz_device_rgb(ctx), 0);
|
||||
long dark = 0;
|
||||
for (long i = 0; i < (long)pix->w * pix->h; i++)
|
||||
if (pix->samples[i * pix->n] < 128) dark++;
|
||||
printf("pages=%d\nbounds=%.0fx%.0f\npixmap=%dx%d\ndark=%ld\n", fz_count_pages(ctx, doc),
|
||||
box.x1 - box.x0, box.y1 - box.y0, pix->w, pix->h, dark);
|
||||
fz_stext_page *text = fz_new_stext_page_from_page(ctx, page, NULL);
|
||||
fz_buffer *buf = fz_new_buffer(ctx, 256);
|
||||
fz_output *out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_print_stext_page_as_text(ctx, out, text);
|
||||
fz_close_output(ctx, out);
|
||||
printf("text=%.*s\n", (int)fz_buffer_storage(ctx, buf, NULL), fz_string_from_buffer(ctx, buf));
|
||||
}
|
||||
fz_catch(ctx) {
|
||||
fprintf(stderr, "mupdf: %s\n", fz_caught_message(ctx));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
# Title
|
||||
|
||||
Text with $e^{i\pi} + 1 = 0$ inline and a note.[^1]
|
||||
|
||||
$$I = \int \rho R^{2} dV$$
|
||||
|
||||
- [x] done
|
||||
- [ ] open
|
||||
|
||||
| a | b |
|
||||
| - | - |
|
||||
| 1 | 2 |
|
||||
|
||||
```rust
|
||||
fn main() {}
|
||||
```
|
||||
|
||||
$$
|
||||
\int_0^1 x\,dx
|
||||
$$
|
||||
|
||||
$$
|
||||
\frac{1}{
|
||||
$$
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A --> B
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A -->
|
||||
```
|
||||
|
||||
[^1]: The note.
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
雷电芽衣 ,zacharyzhang,leidianyayis-Mac-mini.local,08.10.2026 20:49,file:///private/var/folders/g4/sj7njkr93jz0rgmg0b_zqnvr0000gn/T/tmp.mPypHxNqRv/home-ely-doc/Library/Application%20Support/ElyOffice/ely-doc/lok-profile;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
雷电芽衣 ,zacharyzhang,leidianyayis-Mac-mini.local,08.10.2026 20:50,file:///private/var/folders/g4/sj7njkr93jz0rgmg0b_zqnvr0000gn/T/tmp.rkBzVqQEZC/home-ely-show/Library/Application%20Support/ElyOffice/ely-show/lok-profile;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
雷电芽衣 ,zacharyzhang,leidianyayis-Mac-mini.local,08.10.2026 20:50,file:///private/var/folders/g4/sj7njkr93jz0rgmg0b_zqnvr0000gn/T/tmp.rkBzVqQEZC/home-ely-table/Library/Application%20Support/ElyOffice/ely-table/lok-profile;
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
||||
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
||||
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
||||
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
|
||||
office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.presentation">
|
||||
<office:font-face-decls><style:font-face style:name="Nowhere Sans" svg:font-family="'Nowhere Sans'"/></office:font-face-decls>
|
||||
<office:automatic-styles>
|
||||
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
|
||||
</office:automatic-styles>
|
||||
<office:body><office:presentation>
|
||||
<draw:page draw:name="One"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="10cm" svg:height="3cm"><draw:text-box><text:p><text:span text:style-name="T1">Lost font</text:span></text:p></draw:text-box></draw:frame></draw:page>
|
||||
<draw:page draw:name="Two"><draw:frame draw:name="Gone" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:image xlink:href="file:///nowhere/gone.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></draw:page>
|
||||
<draw:page draw:name="Three"><draw:frame draw:name="Empty" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:object xlink:href="./Object 9" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></draw:page>
|
||||
<draw:page draw:name="Four"><draw:frame draw:name="Bad" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:image xlink:href="broken.png" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame><draw:frame draw:name="Junk" svg:x="9cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:image><office:binary-data>bm90IGFuIGltYWdlIGF0IGFsbA==</office:binary-data></draw:image></draw:frame></draw:page>
|
||||
<draw:page draw:name="Five"><draw:frame draw:name="Linked" svg:x="2cm" svg:y="2cm" svg:width="6cm" svg:height="4cm"><draw:object xlink:href="linked.odt" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame></draw:page>
|
||||
</office:presentation></office:body>
|
||||
</office:document>
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
// Outside the content folder; it must never run.
|
||||
window.outsideRan = true;
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { background: var(--ely-bg); color: var(--ely-fg); margin: 0; }
|
||||
textarea { width: 90%; height: 200px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="marker" style="width: 300px; height: 150px; background: rgb(255, 0, 170)"></div>
|
||||
<textarea id="text" aria-label="Text"></textarea>
|
||||
<script src="../web-outside/evil.js"></script>
|
||||
<script>
|
||||
// The probe asks; the page answers through elyQuery.
|
||||
window.report = (step) => window.elyQuery({
|
||||
request: JSON.stringify({
|
||||
step,
|
||||
value: document.getElementById("text").value,
|
||||
focused: document.activeElement && document.activeElement.id,
|
||||
fg: getComputedStyle(document.documentElement).getPropertyValue("--ely-fg").trim(),
|
||||
ratio: window.devicePixelRatio,
|
||||
outside: Boolean(window.outsideRan),
|
||||
rtc: typeof window.RTCPeerConnection,
|
||||
}),
|
||||
onSuccess: () => {},
|
||||
onFailure: (code, message) => console.error("probe query failed", code, message),
|
||||
});
|
||||
document.getElementById("text").focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PERF-01 to PERF-08 on release builds, written as a record."""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import samples
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
APPS = {"ely-doc": "ElyDoc", "ely-table": "ElyTable", "ely-show": "ElyShow",
|
||||
"ely-pdf": "ElyPDF", "ely-tree": "ElyTree", "ely-mark": "ElyMark"}
|
||||
TARGETS = {"PERF-01": 3000, "PERF-02": 100, "PERF-03": 5000, "PERF-04": 5000,
|
||||
"PERF-05": 3000, "PERF-06": 1000, "PERF-07": 2000}
|
||||
|
||||
|
||||
def sh(*args, **kw):
|
||||
return subprocess.run(args, check=True, capture_output=True, text=True, **kw).stdout
|
||||
|
||||
|
||||
def tools(folder):
|
||||
for tool in ["ax-values", "ax-press", "click"]:
|
||||
sh("swiftc", "-O", "-o", os.path.join(folder, tool), os.path.join(ROOT, "tests", "scripts", tool + ".swift"))
|
||||
|
||||
|
||||
class App:
|
||||
"""A release app run, timed from spawn, read via log."""
|
||||
|
||||
def __init__(self, slug, home, args=(), smoke="hold"):
|
||||
self.slug, self.home, self.tools = slug, home, os.path.dirname(home)
|
||||
self.log = os.path.join(home, "Library", "Application Support", "ElyOffice", slug, "ely.log")
|
||||
# Starts may trim the log; each run starts fresh.
|
||||
if os.path.exists(self.log):
|
||||
os.remove(self.log)
|
||||
self.seen = 0
|
||||
env = dict(os.environ, HOME=home, ELY_SMOKE=smoke, ELY_LOCALE="en", ELY_NO_DIALOG="1",
|
||||
ELY_LOG="debug", ELY_STALLS="1")
|
||||
self.start = time.monotonic()
|
||||
self.process = subprocess.Popen([os.path.join(ROOT, "target", "release", slug), *args], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
||||
|
||||
def lines(self):
|
||||
if not os.path.exists(self.log):
|
||||
return []
|
||||
with open(self.log, errors="replace") as log:
|
||||
log.seek(self.seen)
|
||||
return log.read().splitlines()
|
||||
|
||||
def wait(self, pattern, after=None, timeout=60):
|
||||
"""Milliseconds from spawn until a line matches, past `after`."""
|
||||
called = time.monotonic()
|
||||
while time.monotonic() - called < timeout:
|
||||
lines = self.lines()
|
||||
if after is not None:
|
||||
at = next((i for i, line in enumerate(lines) if re.search(after, line)), None)
|
||||
lines = [] if at is None else lines[at + 1:]
|
||||
if any(re.search(pattern, line) for line in lines):
|
||||
return (time.monotonic() - self.start) * 1000
|
||||
time.sleep(0.01)
|
||||
tail = "\n".join(self.lines()[-12:])
|
||||
raise RuntimeError(f"{self.slug}: no {pattern!r} in {timeout} s; the log ends:\n{tail}")
|
||||
|
||||
def ready(self, timeout=60):
|
||||
"""ELY_SMOKE=1: milliseconds until the first frame."""
|
||||
out, _ = self.process.communicate(timeout=timeout)
|
||||
took = (time.monotonic() - self.start) * 1000
|
||||
if f"ready {APPS[self.slug]}" not in out:
|
||||
raise RuntimeError(f"{self.slug} did not report ready: {out!r}")
|
||||
# Its engine may still be starting; the next run waits.
|
||||
for _ in range(300):
|
||||
if subprocess.run(["pgrep", "-f", self.home], capture_output=True).returncode != 0:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return took
|
||||
|
||||
def family(self):
|
||||
out = sh("ps", "-A", "-o", "pid=,ppid=,rss=")
|
||||
rows = [tuple(map(int, line.split())) for line in out.splitlines()]
|
||||
ours, grew = {self.process.pid}, True
|
||||
while grew:
|
||||
grew = False
|
||||
for pid, ppid, _ in rows:
|
||||
if ppid in ours and pid not in ours:
|
||||
ours.add(pid)
|
||||
grew = True
|
||||
return ours, sum(rss for pid, _, rss in rows if pid in ours)
|
||||
|
||||
def memory_mb(self):
|
||||
return self.family()[1] / 1024
|
||||
|
||||
def files(self):
|
||||
ours, _ = self.family()
|
||||
return sum(len(sh("lsof", "-p", str(pid)).splitlines()) for pid in ours)
|
||||
|
||||
def front(self):
|
||||
sh("osascript", "-e", f'tell application "System Events" to set frontmost of (first process whose unix id is {self.process.pid}) to true')
|
||||
time.sleep(0.5)
|
||||
|
||||
def ax(self):
|
||||
return sh(os.path.join(self.tools, "ax-values"), str(self.process.pid))
|
||||
|
||||
def stop(self):
|
||||
ours, _ = self.family()
|
||||
for pid in ours:
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.process.wait()
|
||||
|
||||
|
||||
def keys(script):
|
||||
sh("osascript", "-e", 'tell application "System Events"', "-e", script, "-e", "end tell")
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(len(ordered) * p))]
|
||||
|
||||
|
||||
# A page that failed to paint voids the run.
|
||||
PAINT_FAILED = r"lok: tile \(.*\) failed|pdf: drawing a page failed"
|
||||
|
||||
|
||||
def opened(slug, home, sample, marks, runs):
|
||||
"""Spawn with a file until `marks` all appear, in order."""
|
||||
times, memory = [], []
|
||||
for _ in range(runs):
|
||||
app = App(slug, home, [sample])
|
||||
try:
|
||||
after = None
|
||||
for mark in marks:
|
||||
took = app.wait(mark, after)
|
||||
after = mark
|
||||
failed = [line for line in app.lines() if re.search(PAINT_FAILED, line)]
|
||||
if failed:
|
||||
raise RuntimeError(f"{slug}: {sample} did not paint: {failed[0]}")
|
||||
times.append(took)
|
||||
memory.append(app.memory_mb())
|
||||
finally:
|
||||
app.stop()
|
||||
return times, memory
|
||||
|
||||
|
||||
def laid_out(work, sample, pages):
|
||||
"""The kernel lays `sample` out to exactly `pages` pages."""
|
||||
app = App("ely-doc", os.path.join(work, "home-pages"), [sample])
|
||||
try:
|
||||
app.wait(rf"lok: {pages} pages$", timeout=120)
|
||||
time.sleep(3)
|
||||
counts = [line for line in app.lines() if re.search(r"lok: \d+ pages$", line)]
|
||||
if not counts[-1].endswith(f" {pages} pages"):
|
||||
raise RuntimeError(f"{sample} lays out as {counts[-1]}, not {pages} pages")
|
||||
finally:
|
||||
app.stop()
|
||||
|
||||
|
||||
def perf01(work, runs):
|
||||
rows = []
|
||||
for slug in APPS:
|
||||
home = os.path.join(work, "home-" + slug)
|
||||
first = App(slug, home, smoke="1").ready()
|
||||
daily = [App(slug, home, smoke="1").ready() for _ in range(runs)]
|
||||
rows.append((slug, first, daily))
|
||||
return rows
|
||||
|
||||
|
||||
def perf02(work, runs):
|
||||
"""Key to new tile in ElyDoc, and main-thread stalls meanwhile."""
|
||||
app = App("ely-doc", os.path.join(work, "home-typing"))
|
||||
try:
|
||||
app.wait("opened Untitled")
|
||||
app.front()
|
||||
tick = r"lok: edit to tile (\d+) ms"
|
||||
for n in range(runs):
|
||||
keys('keystroke "x"')
|
||||
# Each key must show; a lost one fails the run.
|
||||
for _ in range(500):
|
||||
if sum(1 for line in app.lines() if re.search(tick, line)) > n:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise RuntimeError(f"ely-doc: key {n + 1} of {runs} never reached a tile")
|
||||
time.sleep(0.15)
|
||||
lines = app.lines()
|
||||
finally:
|
||||
app.stop()
|
||||
ticks = [int(m.group(1)) for line in lines if (m := re.search(tick, line))]
|
||||
if len(ticks) != runs:
|
||||
raise RuntimeError(f"ely-doc: {runs} keys gave {len(ticks)} timings")
|
||||
stalls = [int(m.group(1)) for line in lines if (m := re.search(r"main thread was busy (\d+) ms", line))]
|
||||
return ticks, stalls
|
||||
|
||||
|
||||
def perf06(work, repo, runs):
|
||||
"""History first screen, then a common query's first results."""
|
||||
home = os.path.join(work, "home-ely-tree")
|
||||
first, found = [], []
|
||||
for _ in range(runs):
|
||||
app = App("ely-tree", home, [repo])
|
||||
try:
|
||||
app.wait("tree: opened")
|
||||
app.front()
|
||||
pressed = (time.monotonic() - app.start) * 1000
|
||||
sh(os.path.join(work, "ax-press"), str(app.process.pid), "History")
|
||||
first.append(app.wait(r"history shows \d+ commits, searched false") - pressed)
|
||||
field = next(line for line in app.ax().splitlines() if line.startswith("AXTextField"))
|
||||
x, y = field.rsplit("at=", 1)[1].split(",")
|
||||
sh(os.path.join(work, "click"), x, y)
|
||||
keys('keystroke "part 42"')
|
||||
asked = (time.monotonic() - app.start) * 1000
|
||||
keys("key code 36")
|
||||
found.append(app.wait(r"searched true") - asked)
|
||||
finally:
|
||||
app.stop()
|
||||
return first, found
|
||||
|
||||
|
||||
def raise_main(app):
|
||||
"""The document window as key, once a dialog closed."""
|
||||
sh("osascript", "-e", f'tell application "System Events" to tell (first process whose unix id is {app.process.pid}) '
|
||||
f'to perform action "AXRaise" of window "{APPS[app.slug]}"')
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def perf08(work, cycles=40):
|
||||
"""New and close a document; memory, handles and live documents."""
|
||||
app = App("ely-doc", os.path.join(work, "home-cycles"))
|
||||
docs = r"lok-worker: (\d+) documents open"
|
||||
|
||||
def open_docs():
|
||||
counts = [int(m.group(1)) for line in app.lines() if (m := re.search(docs, line))]
|
||||
return counts[-1] if counts else None
|
||||
|
||||
try:
|
||||
app.wait("opened Untitled")
|
||||
app.front()
|
||||
time.sleep(2)
|
||||
baseline = (app.memory_mb(), app.files(), open_docs())
|
||||
seen = []
|
||||
for n in range(cycles):
|
||||
keys('keystroke "n" using command down')
|
||||
for _ in range(40):
|
||||
if "title=Use template" in app.ax():
|
||||
break
|
||||
time.sleep(0.25)
|
||||
sh(os.path.join(work, "ax-press"), str(app.process.pid), "Use template", "AXButton")
|
||||
app.wait(f"opened Untitled {n + 2} ", timeout=30)
|
||||
time.sleep(1)
|
||||
# A keystroke can miss a window still taking focus.
|
||||
for _ in range(3):
|
||||
app.front()
|
||||
raise_main(app)
|
||||
keys('keystroke "w" using command down')
|
||||
try:
|
||||
app.wait(f"office: closed Untitled {n + 2}$", timeout=5)
|
||||
break
|
||||
except RuntimeError:
|
||||
# A keystroke that lost its Command edits the document.
|
||||
if "title=Don't Save" in app.ax():
|
||||
raise RuntimeError(f"ely-doc: Untitled {n + 2} was edited by a stray key; it asks to save")
|
||||
continue
|
||||
else:
|
||||
said = [line for line in app.lines() if re.search("clos|dialog|opened|focus", line)]
|
||||
raise RuntimeError(f"ely-doc: Untitled {n + 2} never closed: {said[-8:]}")
|
||||
# The kernel lets go in the background.
|
||||
for _ in range(100):
|
||||
if open_docs() == baseline[2]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.5)
|
||||
seen.append((app.memory_mb(), app.files(), open_docs()))
|
||||
finally:
|
||||
app.stop()
|
||||
# The first dialog loads system fonts and AppKit tables once.
|
||||
first, last = seen[0], seen[-1]
|
||||
if last[1] != first[1] or last[2] != baseline[2]:
|
||||
raise RuntimeError(f"ely-doc: after {cycles} cycles {last[1:]} files and documents, "
|
||||
f"not {first[1]} and {baseline[2]}")
|
||||
return baseline, seen
|
||||
|
||||
def environment(found):
|
||||
gpu = re.findall(r"Chipset Model: (.+)", sh("system_profiler", "SPDisplaysDataType"))
|
||||
fonts = sum(len(files) for folder in ["/System/Library/Fonts", "/Library/Fonts"] for _, _, files in os.walk(folder))
|
||||
builds = {slug: hashlib.sha256(open(os.path.join(ROOT, "target", "release", slug), "rb").read()).hexdigest()[:16]
|
||||
for slug in APPS}
|
||||
return {
|
||||
"date": datetime.date.today().isoformat(),
|
||||
"machine": sh("sysctl", "-n", "hw.model").strip(),
|
||||
"cpu": sh("sysctl", "-n", "machdep.cpu.brand_string").strip(),
|
||||
"cores": sh("sysctl", "-n", "hw.ncpu").strip(),
|
||||
"memory_gb": int(sh("sysctl", "-n", "hw.memsize")) // 2**30,
|
||||
"gpu": ", ".join(gpu),
|
||||
"system": f"macOS {platform.mac_ver()[0]}",
|
||||
"fonts": fonts,
|
||||
"builds": builds,
|
||||
"samples": {name: value[1] for name, value in found.items()},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runs", type=int, default=30)
|
||||
parser.add_argument("--only", nargs="*")
|
||||
parser.add_argument("--out", default=os.path.join(ROOT, "docs", "perf"))
|
||||
args = parser.parse_args()
|
||||
want = lambda perf: not args.only or perf in args.only
|
||||
if sys.platform != "darwin":
|
||||
sys.exit("SKIP: the bench drives macOS windows")
|
||||
sh("cargo", "build", "--release", "-q", "--bins", "-p", "ely-lok", "-p", "ely-pdf-engine",
|
||||
*[arg for slug in APPS for arg in ("-p", slug)], cwd=ROOT)
|
||||
git = os.path.join(ROOT, "engines", "git", "bin", "git")
|
||||
found = samples.made(os.path.join(ROOT, "engines", "bench"), git)
|
||||
work = tempfile.mkdtemp()
|
||||
tools(work)
|
||||
rows = []
|
||||
try:
|
||||
if want("PERF-01"):
|
||||
for slug, first, daily in perf01(work, args.runs):
|
||||
rows.append(("PERF-01", f"{slug} first run", [first], None))
|
||||
rows.append(("PERF-01", f"{slug} daily start", daily, None))
|
||||
# The window opens first; the blank document follows the engine.
|
||||
for slug in ["ely-doc", "ely-table", "ely-show"]:
|
||||
blank = []
|
||||
for _ in range(args.runs):
|
||||
app = App(slug, os.path.join(work, "home-" + slug))
|
||||
try:
|
||||
blank.append(app.wait("opened Untitled"))
|
||||
finally:
|
||||
app.stop()
|
||||
rows.append(("PERF-01", f"{slug} blank document ready", blank, None))
|
||||
if want("PERF-02"):
|
||||
ticks, stalls = perf02(work, args.runs)
|
||||
rows.append(("PERF-02", "ElyDoc key to new tile", ticks, None))
|
||||
rows.append(("PERF-02", f"main thread busy >100 ms: {len(stalls)} times", stalls or [0], None))
|
||||
cases = [("PERF-03", "ely-doc", "doc.docx", ["opened doc.docx", r"lok: tile \(\d+, \d+\) shown"]),
|
||||
("PERF-04", "ely-table", "sheet.xlsx", ["opened sheet.xlsx", r"lok: tile \(\d+, \d+\) shown"]),
|
||||
("PERF-05", "ely-pdf", "pages.pdf", ["opened pages.pdf", r"pdf: a tile of page 0 drawn"]),
|
||||
("PERF-07", "ely-mark", "notes.md", ["opened notes.md", r"mark: shown as \"visual\""])]
|
||||
if want("PERF-03"):
|
||||
laid_out(work, found["doc.docx"][0], 100)
|
||||
for perf, slug, name, marks in cases:
|
||||
if want(perf):
|
||||
times, memory = opened(slug, os.path.join(work, "home-" + slug), found[name][0], marks, args.runs)
|
||||
rows.append((perf, f"{APPS[slug]} {name}", times, max(memory)))
|
||||
if want("PERF-06"):
|
||||
first, searched = perf06(work, found["repo"][0], args.runs)
|
||||
rows.append(("PERF-06", "ElyTree history, press to first screen", first, None))
|
||||
rows.append(("PERF-06", "ElyTree query 'part 42', first results", searched, None))
|
||||
held = perf08(work) if want("PERF-08") else None
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
write(args.out, environment(found), rows, held, args.runs)
|
||||
|
||||
|
||||
def write(out, env, rows, held, runs):
|
||||
os.makedirs(out, exist_ok=True)
|
||||
path = os.path.join(out, f"{env['date']}-{env['machine']}.md")
|
||||
lines = [f"# Benchmark {env['date']}", "", "Format: docs/perf/README.md.", "", "```json",
|
||||
json.dumps(env, indent=2), "```", "",
|
||||
"| PERF | Case | Runs | P50 ms | P95 ms | Target ms | Total memory MB |", "| --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for perf, case, times, memory in rows:
|
||||
target = TARGETS.get(perf)
|
||||
p95 = percentile(times, 0.95)
|
||||
verdict = "" if target is None or "busy" in case else (" ✓" if p95 <= target else " ✗")
|
||||
mem = "" if memory is None else f"{memory:.0f}"
|
||||
lines.append(f"| {perf} | {case} | {len(times)} | {percentile(times, 0.5):.0f} | {p95:.0f}{verdict} | "
|
||||
f"{target or ''} | {mem} |")
|
||||
if held:
|
||||
(memory, files, docs), seen = held
|
||||
lines += ["", f"PERF-08, ElyDoc creating and closing a document {len(seen)} times "
|
||||
"(total memory MB / open files / kernel documents):", "",
|
||||
f"before: {memory:.0f}/{files}/{docs}", "",
|
||||
" ".join(f"{memory:.0f}/{files}/{docs}" for memory, files, docs in seen)]
|
||||
lines += ["", "PERF-09 (Agent sessions) belongs to the second release.", ""]
|
||||
with open(path, "w") as record:
|
||||
record.write("\n".join(lines))
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Benchmark samples, made the same way every time (§20.1)."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import subprocess
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
|
||||
def noise_png(seed, width, height):
|
||||
"""An RGB PNG of seeded noise; it does not compress."""
|
||||
rng = random.Random(seed)
|
||||
rows = b"".join(b"\0" + rng.randbytes(width * 3) for _ in range(height))
|
||||
|
||||
def chunk(kind, body):
|
||||
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body))
|
||||
|
||||
head = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", head) + chunk(b"IDAT", zlib.compress(rows, 1)) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' \
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' \
|
||||
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ' \
|
||||
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' \
|
||||
'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"'
|
||||
|
||||
|
||||
def picture(n):
|
||||
cx, cy = 3600000, 2700000
|
||||
return (f'<w:p><w:r><w:drawing><wp:inline><wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{n}" name="p{n}"/>'
|
||||
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
|
||||
f'<pic:pic><pic:nvPicPr><pic:cNvPr id="{n}" name="p{n}.png"/><pic:cNvPicPr/></pic:nvPicPr>'
|
||||
f'<pic:blipFill><a:blip r:embed="img{n}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
|
||||
f'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>'
|
||||
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic>'
|
||||
'</wp:inline></w:drawing></w:r></w:p>')
|
||||
|
||||
|
||||
def docx(path):
|
||||
"""PERF-03: 100 pages, 20 images, under 10 MB."""
|
||||
rng = random.Random(3)
|
||||
words = "office local file engine page table slide note draft review".split()
|
||||
body = []
|
||||
for page in range(100):
|
||||
# The break runs in the heading; Heading 1 drops pageBreakBefore.
|
||||
breaks = '<w:r><w:br w:type="page"/></w:r>' if page else ""
|
||||
body.append(f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>{breaks}<w:r><w:t>Page {page + 1}</w:t></w:r></w:p>')
|
||||
for _ in range(6):
|
||||
text = " ".join(rng.choice(words) for _ in range(60))
|
||||
body.append(f"<w:p><w:r><w:t>{text}</w:t></w:r></w:p>")
|
||||
if page % 5 == 0:
|
||||
body.append(picture(page // 5 + 1))
|
||||
rels = "".join(f'<Relationship Id="img{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
|
||||
f'Target="media/p{n}.png"/>' for n in range(1, 21))
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>'
|
||||
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>')
|
||||
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
|
||||
archive.writestr("word/_rels/document.xml.rels", f'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>')
|
||||
archive.writestr("word/document.xml", f'<w:document {W}><w:body>{"".join(body)}</w:body></w:document>')
|
||||
for n in range(1, 21):
|
||||
archive.writestr(f"word/media/p{n}.png", noise_png(n, 400, 300), zipfile.ZIP_STORED)
|
||||
|
||||
|
||||
def xlsx(path):
|
||||
"""PERF-04: 100,000 values and 10,000 simple formulas."""
|
||||
rows = []
|
||||
for r in range(1, 10001):
|
||||
cells = "".join(f'<c r="{chr(65 + c)}{r}"><v>{(r * 7 + c * 13) % 1000}</v></c>' for c in range(10))
|
||||
rows.append(f'<row r="{r}">{cells}<c r="K{r}"><f>SUM(A{r}:J{r})</f></c></row>')
|
||||
ns = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' \
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
||||
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>')
|
||||
archive.writestr("xl/workbook.xml", f'<workbook {ns}><sheets><sheet name="Data" sheetId="1" r:id="s1"/></sheets></workbook>')
|
||||
archive.writestr("xl/worksheets/sheet1.xml", f'<worksheet {ns}><sheetData>{"".join(rows)}</sheetData></worksheet>')
|
||||
|
||||
|
||||
def pdf(path):
|
||||
"""PERF-05: 1000 pages, about 200 MB, one image a page."""
|
||||
rng = random.Random(5)
|
||||
offsets, out = [], open(path, "wb")
|
||||
out.write(b"%PDF-1.7\n")
|
||||
|
||||
def obj(n, body):
|
||||
offsets.append((n, out.tell()))
|
||||
out.write(f"{n} 0 obj\n".encode() + body + b"\nendobj\n")
|
||||
|
||||
pages = 1000
|
||||
kids = " ".join(f"{3 + i * 3} 0 R" for i in range(pages))
|
||||
obj(1, b"<< /Type /Catalog /Pages 2 0 R >>")
|
||||
obj(2, f"<< /Type /Pages /Kids [{kids}] /Count {pages} >>".encode())
|
||||
for i in range(pages):
|
||||
page, content, image = 3 + i * 3, 4 + i * 3, 5 + i * 3
|
||||
pixels = rng.randbytes(240 * 280 * 3)
|
||||
obj(page, f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {content} 0 R "
|
||||
f"/Resources << /XObject << /I {image} 0 R >> /Font << /F <</Type /Font /Subtype /Type1 /BaseFont /Helvetica>> >> >> >>".encode())
|
||||
draw = f"q 480 0 0 560 66 160 cm /I Do Q BT /F 24 Tf 72 100 Td (Page {i + 1}) Tj ET".encode()
|
||||
obj(content, f"<< /Length {len(draw)} >>\nstream\n".encode() + draw + b"\nendstream")
|
||||
obj(image, f"<< /Type /XObject /Subtype /Image /Width 240 /Height 280 /ColorSpace /DeviceRGB "
|
||||
f"/BitsPerComponent 8 /Length {len(pixels)} >>\nstream\n".encode() + pixels + b"\nendstream")
|
||||
xref = out.tell()
|
||||
count = 3 + pages * 3
|
||||
places = dict(offsets)
|
||||
out.write(f"xref\n0 {count}\n0000000000 65535 f \n".encode())
|
||||
for n in range(1, count):
|
||||
out.write(f"{places[n]:010d} 00000 n \n".encode())
|
||||
out.write(f"trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode())
|
||||
out.close()
|
||||
|
||||
|
||||
def repo(path, git):
|
||||
"""PERF-06: a repository of 100,000 commits."""
|
||||
subprocess.run([git, "init", "-q", "-b", "main", path], check=True)
|
||||
stream = []
|
||||
for n in range(1, 100001):
|
||||
message = f"Change {n}: {'fix' if n % 3 else 'add'} part {n % 97}\n".encode()
|
||||
body = f"line {n}\n".encode()
|
||||
stream.append(b"commit refs/heads/main\n")
|
||||
stream.append(f"committer Bench <bench@ely.local> {1700000000 + n} +0000\n".encode())
|
||||
stream.append(f"data {len(message)}\n".encode() + message)
|
||||
stream.append(f"M 644 inline part{n % 97}.txt\ndata {len(body)}\n".encode() + body)
|
||||
subprocess.run([git, "-C", path, "fast-import", "--quiet"], input=b"".join(stream), check=True)
|
||||
subprocess.run([git, "-C", path, "checkout", "-q", "main"], check=True)
|
||||
|
||||
|
||||
def markdown(path):
|
||||
"""PERF-07: a 1 MB Markdown document."""
|
||||
rng = random.Random(7)
|
||||
words = "local file engine page table slide note draft review commit".split()
|
||||
parts, size, n = [], 0, 0
|
||||
while size < 1024 * 1024:
|
||||
n += 1
|
||||
block = f"## Section {n}\n\n" + " ".join(rng.choice(words) for _ in range(120)) + \
|
||||
f"\n\n- item {n}\n- **bold** and `code`\n\n```rust\nfn f{n}() -> u32 {{ {n} }}\n```\n\n"
|
||||
parts.append(block)
|
||||
size += len(block)
|
||||
with open(path, "w") as out:
|
||||
out.write("".join(parts))
|
||||
|
||||
|
||||
def made(folder, git):
|
||||
"""Each sample, made once, with its SHA-256."""
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
makers = {"doc.docx": docx, "sheet.xlsx": xlsx, "pages.pdf": pdf, "notes.md": markdown}
|
||||
found = {}
|
||||
for name, make in makers.items():
|
||||
path = os.path.join(folder, name)
|
||||
if not os.path.exists(path):
|
||||
make(path)
|
||||
found[name] = (path, hashlib.sha256(open(path, "rb").read()).hexdigest())
|
||||
path = os.path.join(folder, "repo")
|
||||
if not os.path.exists(path):
|
||||
repo(path, git)
|
||||
head = subprocess.run([git, "-C", path, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
|
||||
found["repo"] = (path, head)
|
||||
return found
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Long editing and switching in ElyDoc, checked as it goes."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import bench
|
||||
|
||||
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
||||
|
||||
|
||||
def words(path):
|
||||
"""The saved text, read without LibreOffice; a broken file raises."""
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
body = ET.fromstring(archive.read("word/document.xml"))
|
||||
# Runs join without spaces; a word may span two.
|
||||
paragraphs = ("".join(node.text or "" for node in p.iter(W + "t")) for p in body.iter(W + "p"))
|
||||
return " ".join(paragraphs).split()
|
||||
|
||||
|
||||
def check(paths, n):
|
||||
"""What the saved files get wrong after `n` cycles."""
|
||||
a, b = words(paths["a.docx"]), words(paths["b.docx"])
|
||||
problems = [f"a lacks A{k}" for k in range(1, n + 1) if f"A{k}" not in a]
|
||||
problems += [f"b keeps B{k}" for k in range(1, n + 1) if f"B{k}" in b]
|
||||
if any(word.startswith("A") and word[1:].isdigit() for word in b):
|
||||
problems.append("b holds a's text")
|
||||
return problems
|
||||
|
||||
|
||||
def saves(app):
|
||||
"""The app's last save lines, for a failure."""
|
||||
return [line[-90:] for line in app.lines() if "save" in line][-6:]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--minutes", type=float, default=480)
|
||||
args = parser.parse_args()
|
||||
if sys.platform != "darwin":
|
||||
sys.exit("SKIP: the soak drives macOS windows")
|
||||
work = tempfile.mkdtemp()
|
||||
bench.tools(work)
|
||||
fixture = os.path.join(bench.ROOT, "tests", "fixtures", "office", "t.docx")
|
||||
paths = {name: os.path.join(work, name) for name in ["a.docx", "b.docx"]}
|
||||
for path in paths.values():
|
||||
shutil.copy(fixture, path)
|
||||
app = bench.App("ely-doc", os.path.join(work, "home"), list(paths.values()))
|
||||
def save():
|
||||
"""Saves through the menu: a keystroke can be lost."""
|
||||
bench.sh("osascript", "-e", f'tell application "System Events" to tell (first process whose unix id is {app.process.pid}) '
|
||||
'to click menu item "Save" of menu "File" of menu bar 1')
|
||||
|
||||
def press(title):
|
||||
"""Chooses a tab, then puts the keyboard in its document."""
|
||||
bench.sh(os.path.join(work, "ax-press"), str(app.process.pid), title, "AXRadioButton")
|
||||
time.sleep(0.5)
|
||||
area = next(line for line in app.ax().splitlines() if line.startswith("AXTextArea"))
|
||||
x, y = area.rsplit("at=", 1)[1].split(",")
|
||||
bench.sh(os.path.join(work, "click"), x, y)
|
||||
# The click may land mid-word; typing goes at the end.
|
||||
bench.keys("key code 119 using command down")
|
||||
time.sleep(0.3)
|
||||
|
||||
try:
|
||||
app.wait("opened b.docx")
|
||||
app.front()
|
||||
end, n, memory = time.monotonic() + args.minutes * 60, 0, []
|
||||
while time.monotonic() < end:
|
||||
n += 1
|
||||
press("a.docx")
|
||||
bench.keys(f'keystroke " A{n} "')
|
||||
save()
|
||||
press("b.docx")
|
||||
bench.keys(f'keystroke "B{n}"')
|
||||
time.sleep(0.3)
|
||||
bench.keys('keystroke "z" using command down')
|
||||
save()
|
||||
time.sleep(0.5)
|
||||
if n % 10 == 0:
|
||||
# Saves finish in their own time; give them ten seconds.
|
||||
for _ in range(40):
|
||||
problems = check(paths, n)
|
||||
if not problems:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if problems:
|
||||
raise RuntimeError(f"cycle {n}: {problems[:5]}; a ends {words(paths['a.docx'])[-8:]}; log: {saves(app)}")
|
||||
memory.append(app.memory_mb())
|
||||
print(f"cycle {n}: ok, {memory[-1]:.0f} MB", flush=True)
|
||||
finally:
|
||||
app.stop()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
print(f"ok: {n} cycles in {args.minutes} min; memory MB {memory[0]:.0f} → {memory[-1]:.0f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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