@@ -0,0 +1,568 @@
1+#!/usr/bin/env python3
2+"""Run the test- arms of tests/lib/*.hoon under urbit eval.
3+
4+ tools/test-arms every suite, in one eval
5+ tools/test-arms jibb-store 'jibb-program-*'
6+ tools/test-arms --shard 2/4 the second of four balanced shards
7+ tools/test-arms --list the suites, their arms and reports
8+ tools/test-arms --report jibb-poke-bridge
9+ tools/test-arms --update jibb-poke-bridge
10+ tools/test-arms --copies only the my-app copy check
11+
12+The ship runs the same arms with -test /=jibb-dev=/tests (tools/ship
13+test). Here each suite gets the subject Ford would give it on the
14+jibb-dev desk: every /+ library and every /* file, found where tools/ship
15+assembles them, on top of zuse. Many suites share one eval, so the
16+library is built once per eval. Each arm runs under mule and prints OK,
17+FAILED or CRASHED, as -test does, with what it returned or the trace it
18+crashed with, placed in the suite and the library. A suite that doesn't
19+build is reported as FAILED (build) after the eval is retried one suite
20+at a time.
21+
22+A suite whose core has a `report` arm and an `expected` /* file is a
23+golden report: --report prints report's text, --update writes it to the
24+expected file, and a failing arm shows the difference.
25+
26+tests/lib/jibb.hoon, which tools/ship generates from tests/golden, is
27+left out: tools/golden runs those cases under eval.
28+
29+Golden files, the /* %txt files bound to a face with "expected" in its
30+name, must end in exactly one newline (a wain can't show it) and hold no
31+line that starts "BAD " or ends ": BAD" (a check's own failure marker).
32+That is checked before any eval, as are, when no suite or shard is named,
33+the files tests/my-app-copies.txt lists, which must match their my-app
34+sources (the jibb-dev desk has no my-app; --copies checks only them).
35+
36+Exit status: 0 when every arm passes, 1 when one fails, 2 on a usage
37+error.
38+"""
39+
40+import argparse
41+import difflib
42+import fnmatch
43+import importlib.machinery
44+import importlib.util
45+import os
46+import re
47+import sys
48+import time
49+
50+sys.dont_write_bytecode = True
51+TOOLS = os.path.dirname(os.path.realpath(__file__))
52+sys.path.insert(0, TOOLS)
53+
54+import urbit_eval as ue # noqa: E402
55+from noun import atom_to_bytes, to_list # noqa: E402
56+
57+ROOT = os.path.dirname(TOOLS)
58+LIB_DIR = os.path.join(ROOT, 'tests', 'lib')
59+COPIES = os.path.join(ROOT, 'tests', 'my-app-copies.txt')
60+GENERATED = 'jibb' # tests/lib/jibb.hoon, from tools/ship gen-tests
61+LIBRARY = 'jibb' # /+ jibb: the library under test, --lib
62+
63+# For balancing shards: about how many seconds each suite's arms take
64+# under eval, debug on, beyond building the libraries (measured
65+# 2026-11-03, one suite at a time, four at once on four CPUs). Each
66+# eval builds jibb.hoon first, about 17 s, and each /+ library the
67+# shard's suites use once, as LIB_COSTS says. A suite not listed counts
68+# as DEFAULT_COST.
69+COSTS = {
70+ 'jibb-deferred-prepared': 32, 'jibb-todo-full': 23, 'jibb-bridge': 20,
71+ 'jibb-api-evidence': 19, 'jibb-api-paths': 18, 'jibb-doc-examples': 17,
72+ 'jibb-program-init': 12, 'jibb-cell-branch': 11,
73+ 'jibb-direct-gate-source': 11, 'jibb-ask-guards': 10,
74+ 'jibb-buttons': 10, 'jibb-deferred-resolver': 10, 'jibb-empty': 10,
75+ 'jibb-import': 10, 'jibb-page-main': 10,
76+ 'jibb-constructor-fit-guards': 9, 'jibb-deferred': 9,
77+ 'jibb-deferred-context': 9, 'jibb-full-overlap-graph': 9,
78+ 'jibb-gate-free-guards': 9, 'jibb-arithmetic-guards': 8,
79+ 'jibb-closed-gate-guards': 8, 'jibb-direct-gate-guards': 8,
80+ 'jibb-full-overlap-basic': 8, 'jibb-program-update': 8,
81+ 'jibb-gate-free-source': 7, 'jibb-store': 6,
82+}
83+DEFAULT_COST = 3
84+LIB_COSTS = {'jibb-import': 20}
85+
86+HEADER = re.compile(r'^(/\+|/\*)\s+(.*)$')
87+STAR = re.compile(r'^(\S+)\s+%(\S+)\s+(/\S+)$')
88+ARM = re.compile(r'^\+\+ (test-[a-z0-9-]+)\s*$', re.M)
89+REPORT = re.compile(r'^\+\+ report\s*$', re.M)
90+
91+
92+class Unusable(Exception):
93+ pass
94+
95+
96+def load_ship():
97+ """tools/ship as a module, for its desk assembly."""
98+ path = os.path.join(TOOLS, 'ship')
99+ loader = importlib.machinery.SourceFileLoader('jibb_ship_tool', path)
100+ spec = importlib.util.spec_from_loader('jibb_ship_tool', loader)
101+ mod = importlib.util.module_from_spec(spec)
102+ loader.exec_module(mod)
103+ return mod
104+
105+
106+class Suite:
107+ """One tests/lib file: its libraries, files, arms and core."""
108+
109+ def __init__(self, path, desk):
110+ self.path = path
111+ self.name = os.path.splitext(os.path.basename(path))[0]
112+ self.rel = ue.display_path(path)
113+ with open(path, encoding='utf-8') as f:
114+ text = f.read()
115+ at = text.find('\n|%\n')
116+ if at < 0:
117+ raise Unusable('%s: no |%% core after its runes' % self.rel)
118+ head = text[:at + 1].split('\n')[:-1]
119+ self.libs, self.files = [], []
120+ for n, line in enumerate(head, 1):
121+ if not line.strip() or line.startswith('::'):
122+ continue
123+ m = HEADER.match(line)
124+ if not m:
125+ raise Unusable('%s:%d: only /+ and /* lines may come before '
126+ 'the core, not %s' % (self.rel, n, line))
127+ if m.group(1) == '/+':
128+ for lib in (l.strip() for l in m.group(2).split(',')):
129+ self.libs.append(lib)
130+ continue
131+ s = STAR.match(m.group(2).strip())
132+ if not s:
133+ raise Unusable('%s:%d: a /* line is FACE %%MARK /PATH, not %s'
134+ % (self.rel, n, m.group(2)))
135+ face, mark, clay = s.groups()
136+ if mark not in ('txt', 'jibb'):
137+ raise Unusable('%s:%d: only %%txt and %%jibb files can be '
138+ 'bound here, not %%%s' % (self.rel, n, mark))
139+ self.files.append((face, mark, desk_file(desk, clay, self.rel, n)))
140+ # The header lines become comments, so the core keeps its line
141+ # numbers in error reports.
142+ self.core = '\n'.join('::' + l[2:] if l.startswith(('/+', '/*'))
143+ else l for l in head) + text[at:]
144+ self.arms = ARM.findall(text[at:])
145+ self.reports = bool(REPORT.search(text[at:]))
146+ self.expected = next((p for f, _, p in self.files if f == 'expected'),
147+ None)
148+
149+ def cost(self):
150+ return COSTS.get(self.name, DEFAULT_COST)
151+
152+
153+def desk_file(desk, clay, rel, n):
154+ """The repo file a /* path names on the assembled jibb-dev desk."""
155+ parts = clay.strip('/').split('/')
156+ if len(parts) < 2:
157+ raise Unusable('%s:%d: %s has no file extension' % (rel, n, clay))
158+ desk_rel = os.path.join(*parts[:-1]) + '.' + parts[-1]
159+ entry = desk.files.get(desk_rel)
160+ if entry is None:
161+ raise Unusable('%s:%d: %s is not on the jibb-dev desk'
162+ % (rel, n, clay))
163+ path = os.path.join(ROOT, entry[1])
164+ if not os.path.isfile(path):
165+ raise Unusable('%s:%d: %s is generated, not a repo file'
166+ % (rel, n, clay))
167+ return path
168+
169+
170+def lib_source(desk, name):
171+ """A /+ library's file, as Ford finds it: /lib/a-b.hoon, /lib/a/b.hoon."""
172+ for rel in (os.path.join('lib', name + '.hoon'),
173+ os.path.join('lib', *name.split('-')) + '.hoon'):
174+ entry = desk.files.get(rel)
175+ if entry is not None:
176+ return os.path.join(ROOT, entry[1])
177+ raise Unusable('no /lib/%s.hoon on the jibb-dev desk' % name)
178+
179+
180+# ---------------------------------------------------------------- copies
181+
182+
183+def check_copies():
184+ """The tests/ files that copy my-app sources must match them."""
185+ problems, pairs = [], 0
186+ with open(COPIES, encoding='utf-8') as f:
187+ lines = f.read().split('\n')
188+ for n, line in enumerate(lines, 1):
189+ if not line.strip() or line.startswith('#'):
190+ continue
191+ words = line.split(None, 2)
192+ without = None
193+ if len(words) == 3:
194+ if not words[2].startswith('without '):
195+ words = []
196+ else:
197+ without = words[2][len('without '):].encode() + b'\n'
198+ if len(words) < 2:
199+ problems.append('tests/my-app-copies.txt:%d: COPY SOURCE '
200+ '[without LINE], not %s' % (n, line))
201+ continue
202+ copy, source = words[0], words[1]
203+ try:
204+ with open(os.path.join(ROOT, copy), 'rb') as f:
205+ got = f.read()
206+ with open(os.path.join(ROOT, source), 'rb') as f:
207+ want = f.read()
208+ except OSError as e:
209+ problems.append('tests/my-app-copies.txt:%d: %s' % (n, e))
210+ continue
211+ if without is not None:
212+ if without not in want:
213+ problems.append('tests/my-app-copies.txt:%d: %s has no line %s'
214+ % (n, source, without.decode().rstrip()))
215+ continue
216+ want = want.replace(without, b'', 1)
217+ pairs += 1
218+ if got != want:
219+ problems.append('%s differs from %s%s' % (
220+ copy, source, '' if without is None else
221+ ' without %s' % without.decode().rstrip()))
222+ return pairs, problems
223+
224+
225+def golden_problems(text):
226+ """What is wrong with a golden file's or report's bytes, or None."""
227+ if not text.endswith(b'\n') or text.endswith(b'\n\n'):
228+ return 'does not end in exactly one newline'
229+ for line in text.split(b'\n'):
230+ if line.startswith(b'BAD ') or line.endswith(b': BAD'):
231+ return 'holds a BAD line: %s' % line.decode('utf-8', 'replace')
232+ return None
233+
234+
235+def check_goldens(suites):
236+ problems = []
237+ for suite in suites:
238+ for face, mark, path in suite.files:
239+ if mark != 'txt' or 'expected' not in face:
240+ continue
241+ with open(path, 'rb') as f:
242+ problem = golden_problems(f.read())
243+ if problem:
244+ problems.append('%s (%s in %s) %s' % (
245+ ue.display_path(path), face, suite.name, problem))
246+ return problems
247+
248+
249+# ---------------------------------------------------------------- program
250+
251+
252+def suite_expression(i, suite, binds):
253+ """A wain of OK/FAILED/CRASHED lines for one suite's arms, or, with
254+ binds=report, the text of its report arm."""
255+ pins = []
256+ for lib in suite.libs:
257+ pins.append('%s=%s' % (lib, 'jibb-lib' if lib == LIBRARY
258+ else 'lib-' + lib))
259+ for face, mark, _ in suite.files:
260+ cord = 's%d-%s' % (i, face)
261+ pins.append('%s=`@t`%s' % (face, cord) if mark == 'jibb'
262+ else '%s=(to-wain:format %s)' % (face, cord))
263+ subject = (':* ' + '\n '.join(pins + ['arms-show=arms-show', '..zuse'])
264+ + '\n==\n')
265+ show = '(arms-show p.r)'
266+ if binds == 'report':
267+ tail = '^- wain\n~[`@t`report:suite]\n'
268+ else:
269+ checks = []
270+ for arm in suite.arms:
271+ label = '%s %s' % (suite.name, arm)
272+ checks.append(
273+ ' =/ r (mule |.(`tang`%s:suite))\n'
274+ ' ^- wain\n'
275+ ' ?: ?=(%%| -.r) [\'CRASHED %s\' %s]\n'
276+ ' ?~ p.r ~[\'OK %s\']\n'
277+ ' [\'FAILED %s\' %s]\n' % (arm, label, show, label,
278+ label, show))
279+ tail = ('^- wain\n%- zing\n^- (list wain)\n:~\n'
280+ + ' ::\n'.join(checks) + '==\n')
281+ return ('=> ' + subject + '=/ suite\n', tail)
282+
283+
284+def program_for(suites, lib, debug, mode='arms'):
285+ """One eval program for several suites, and the files it binds."""
286+ prog = ue.library_program(lib, debug=debug)
287+ prog.add('=/ jibb-lib .\n'
288+ '=/ arms-show\n'
289+ ' |= t=tang\n'
290+ ' ^- wain\n'
291+ ' =| out=wain\n'
292+ ' |- ^- wain\n'
293+ ' ?~ t (flop out)\n'
294+ ' =/ w=wall (wash [2 100] i.t)\n'
295+ ' |- ^- wain\n'
296+ ' ?~ w ^$(t t.t)\n'
297+ ' $(w t.w, out [(crip i.w) out])',
298+ '(test-arms)', 'harness')
299+ libs = sorted({l for s in suites for l in s.libs if l != LIBRARY})
300+ for name, path in ((l, lib_source(DESK, l)) for l in libs):
301+ # Compiled on zuse, as Ford compiles a /+ library.
302+ prog.add('=/ lib-%s\n=> ..zuse' % name, '(test-arms)', 'harness')
303+ prog.add(ue.read_text(path), ue.display_path(path), 'expr')
304+ for i, suite in enumerate(suites):
305+ for face, mark, path in suite.files:
306+ with open(path, 'rb') as f:
307+ data = f.read()
308+ pieces = [p for p in ue.cord_pieces(data) if p]
309+ value = ("''" if not pieces else '(rap 3 `(list @t)`~[%s])'
310+ % ' '.join(ue.cord_literal(p) for p in pieces))
311+ prog.add('=/ s%d-%s=@t %s' % (i, face, value),
312+ '(%s %s)' % (suite.rel, face), 'harness')
313+ if mode == 'report':
314+ suite = suites[0]
315+ head, tail = suite_expression(0, suite, 'report')
316+ prog.add(head, '(test-arms)', 'harness')
317+ prog.add(suite.core, suite.rel, 'expr')
318+ prog.add(tail, '(test-arms)', 'harness')
319+ return prog
320+ prog.add('^- wain\n%- zing\n^- (list wain)\n:~', '(test-arms)',
321+ 'harness')
322+ for i, suite in enumerate(suites):
323+ head, tail = suite_expression(i, suite, 'arms')
324+ prog.add(' :: ' + suite.rel + '\n' + head, '(test-arms)', 'harness')
325+ prog.add(suite.core, suite.rel, 'expr')
326+ prog.add(tail, '(test-arms)', 'harness')
327+ prog.add('==', '(test-arms)', 'harness')
328+ return prog
329+
330+
331+def wain_of(res):
332+ return [atom_to_bytes(l).decode('utf-8', 'replace')
333+ for l in to_list(res.noun)]
334+
335+
336+def placed(prog, lines):
337+ """The lines, with each CRASHED arm's trace placed in the suites and
338+ the library, as eval places its own, and an excerpt of the innermost
339+ spot in them."""
340+ out, i = [], 0
341+ while i < len(lines):
342+ out.append(lines[i])
343+ crashed = lines[i].startswith('CRASHED ')
344+ i += 1
345+ block = []
346+ while i < len(lines) and lines[i].startswith(' '):
347+ block.append(lines[i])
348+ i += 1
349+ if not crashed:
350+ out += block
351+ continue
352+ mapped, spots = ue.map_trace(prog, block, keep=('lib', 'expr'))
353+ out += ue.fold_repeats(mapped)
354+ # mule's trace, unlike eval's, starts at the innermost spot.
355+ at = next((sp for sp in spots if sp.kind in ('lib', 'expr')), None)
356+ if at is not None:
357+ out.append(' at %s' % at.label())
358+ ex = at.excerpt(indent=' ')
359+ if ex:
360+ out += ex.split('\n')
361+ return out
362+
363+
364+def run(suites, lib, debug, timeout):
365+ """Run suites in one eval; on a build failure, one suite at a time."""
366+ prog = program_for(suites, lib, debug)
367+ res = ue.run_eval(prog, jam=True, timeout=timeout)
368+ if res.ok:
369+ return placed(prog, wain_of(res))
370+ if res.timed_out:
371+ return (['FAILED %s (timeout)' % s.name for s in suites]
372+ + [' the eval ran past %gs; run the suites one at a time '
373+ 'to find the slow one' % timeout])
374+ if len(suites) > 1:
375+ out = []
376+ for suite in suites:
377+ out += run([suite], lib, debug, timeout)
378+ return out
379+ suite = suites[0]
380+ what = ("doesn't build" if res.is_compile_error() else
381+ 'crashed: %s' % res.describe())
382+ # The harness's own frames only wrap the suite's.
383+ report = ue.failure_report(prog, res, '%s %s' % (suite.rel, what),
384+ keep=('lib', 'expr'))
385+ return (['FAILED %s (build)' % suite.name]
386+ + [' ' + l for l in report.split('\n')])
387+
388+
389+def report_text(suite, lib, debug, timeout):
390+ prog = program_for([suite], lib, debug, mode='report')
391+ res = ue.run_eval(prog, jam=True, timeout=timeout)
392+ if not res.ok:
393+ raise Unusable(ue.failure_report(
394+ prog, res, '%s: its report arm failed' % suite.rel))
395+ return '\n'.join(wain_of(res)) + '\n'
396+
397+
398+# ---------------------------------------------------------------- shards
399+
400+
401+def shard(suites, index, count):
402+ """The index-th of count shards, balanced by COSTS and LIB_COSTS
403+ (largest first, each to the shard it makes least loaded), so the same
404+ suites always go together."""
405+ loads = [[0, [], set()] for _ in range(count)]
406+
407+ def added(load, suite):
408+ return suite.cost() + sum(LIB_COSTS.get(l, 0) for l in suite.libs
409+ if l not in load[2])
410+
411+ for suite in sorted(suites, key=lambda s: (-s.cost(), s.name)):
412+ best = min(loads, key=lambda l: l[0] + added(l, suite))
413+ best[0] += added(best, suite)
414+ best[1].append(suite)
415+ best[2].update(suite.libs)
416+ return sorted(loads[index - 1][1], key=lambda s: s.name)
417+
418+
419+def select(folder, names):
420+ every = sorted(os.path.splitext(f)[0] for f in os.listdir(folder)
421+ if f.endswith('.hoon')
422+ and os.path.splitext(f)[0] != GENERATED)
423+ if not names:
424+ return every
425+ picked = []
426+ for pattern in names:
427+ pattern = os.path.splitext(os.path.basename(pattern))[0]
428+ hits = fnmatch.filter(every, pattern)
429+ if not hits:
430+ raise Unusable('no suite in %s matches %s'
431+ % (ue.display_path(folder), pattern))
432+ picked += [h for h in hits if h not in picked]
433+ return picked
434+
435+
436+def main(argv):
437+ ap = argparse.ArgumentParser(
438+ prog='tools/test-arms',
439+ formatter_class=argparse.RawDescriptionHelpFormatter,
440+ description=__doc__.split('\n\n')[0])
441+ ap.add_argument('suites', nargs='*', metavar='SUITE',
442+ help='tests/lib suites by name or glob (default: all)')
443+ ap.add_argument('--shard', metavar='I/N',
444+ help='run only the I-th of N balanced shards')
445+ ap.add_argument('--dir', default=LIB_DIR, metavar='DIR',
446+ help='the folder of suites (default: tests/lib)')
447+ ap.add_argument('--lib', default=ue.DEFAULT_LIB, metavar='PATH',
448+ help='the library (default: jibb-dev/lib/jibb.hoon)')
449+ ap.add_argument('--timeout', type=float, default=1800, metavar='SECONDS',
450+ help='how long one eval may run (default: 1800)')
451+ ap.add_argument('--no-debug', dest='debug', action='store_false',
452+ help="leave out !:, for timings; errors lose positions")
453+ ap.add_argument('--list', action='store_true',
454+ help='print the suites that would run, and their arms')
455+ mode = ap.add_mutually_exclusive_group()
456+ mode.add_argument('--report', action='store_true',
457+ help="print one suite's report arm")
458+ mode.add_argument('--update', action='store_true',
459+ help="write suites' report arms to their expected "
460+ "files")
461+ mode.add_argument('--copies', action='store_true',
462+ help='only check tests/my-app-copies.txt')
463+ opts = ap.parse_args(argv)
464+
465+ if opts.copies or not (opts.suites or opts.shard or opts.list
466+ or opts.report or opts.update
467+ or os.path.realpath(opts.dir) != LIB_DIR):
468+ pairs, problems = check_copies()
469+ for p in problems:
470+ print('FAILED my-app copy: %s' % p)
471+ if opts.copies:
472+ if not problems:
473+ print('%d my-app copies match their sources' % pairs)
474+ return 1 if problems else 0
475+ if problems:
476+ return 1
477+
478+ global DESK
479+ try:
480+ if not os.path.isfile(opts.lib):
481+ raise Unusable('no library at %s' % ue.display_path(opts.lib))
482+ ship = load_ship()
483+ try:
484+ DESK = ship.assemble('jibb-dev')
485+ except ship.Unable as e:
486+ raise Unusable(str(e))
487+ if not os.path.isdir(opts.dir):
488+ raise Unusable('no folder %s' % ue.display_path(opts.dir))
489+ suites = [Suite(os.path.join(opts.dir, n + '.hoon'), DESK)
490+ for n in select(opts.dir, opts.suites)]
491+ if opts.shard:
492+ m = re.fullmatch(r'(\d+)/(\d+)', opts.shard)
493+ if not m or not 1 <= int(m.group(1)) <= int(m.group(2)):
494+ raise Unusable('--shard takes I/N with 1 <= I <= N')
495+ suites = shard(suites, int(m.group(1)), int(m.group(2)))
496+ if opts.list:
497+ for s in suites:
498+ print('%s (%ds)%s: %s' % (s.name, s.cost(),
499+ ', report' if s.reports else '',
500+ ' '.join(s.arms) or 'no arms'))
501+ return 0
502+ if opts.report or opts.update:
503+ golden = [s for s in suites if s.reports and s.expected]
504+ if opts.report and len(suites) != 1:
505+ raise Unusable('--report takes one suite')
506+ if not golden:
507+ raise Unusable('no report arm with an expected file in %s'
508+ % ', '.join(s.name for s in suites))
509+ for s in golden:
510+ text = report_text(s, opts.lib, opts.debug, opts.timeout)
511+ if opts.report:
512+ sys.stdout.write(text)
513+ continue
514+ problem = golden_problems(text.encode('utf-8'))
515+ if problem:
516+ raise Unusable('%s: not updated, its report %s'
517+ % (ue.display_path(s.expected), problem))
518+ with open(s.expected, 'w', encoding='utf-8') as f:
519+ f.write(text)
520+ print('updated %s' % ue.display_path(s.expected))
521+ return 0
522+ if not suites:
523+ print('no suites in this shard')
524+ return 0
525+ problems = check_goldens(suites)
526+ if problems:
527+ for p in problems:
528+ print('FAILED golden file: %s' % p)
529+ return 1
530+ start = time.time()
531+ lines = run(suites, opts.lib, opts.debug, opts.timeout)
532+ except (Unusable, ue.ToolError) as e:
533+ print('test-arms: %s' % e, file=sys.stderr)
534+ return 2
535+
536+ failed = []
537+ for line in lines:
538+ print(line)
539+ if line.startswith(('FAILED ', 'CRASHED ')):
540+ failed.append(line.split()[1])
541+ for s in suites:
542+ if s.name in failed and s.reports and s.expected:
543+ show_difference(s, opts)
544+ arms = sum(1 for l in lines if l.startswith(('OK ', 'FAILED ',
545+ 'CRASHED ')))
546+ print('%d suites, %d arms: %s in %.0f s' % (
547+ len(suites), arms,
548+ 'all passed' if not failed else '%d failed' % len(failed),
549+ time.time() - start))
550+ return 1 if failed else 0
551+
552+
553+def show_difference(suite, opts):
554+ try:
555+ text = report_text(suite, opts.lib, opts.debug, opts.timeout)
556+ except Unusable:
557+ return
558+ with open(suite.expected, encoding='utf-8') as f:
559+ want = f.read()
560+ sys.stdout.writelines(difflib.unified_diff(
561+ want.splitlines(keepends=True), text.splitlines(keepends=True),
562+ fromfile=ue.display_path(suite.expected), tofile='report'))
563+
564+
565+DESK = None
566+
567+if __name__ == '__main__':
568+ sys.exit(main(sys.argv[1:]))