| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248 |
- #!/usr/bin/env python3
- """End-to-end UI test: C writes a real .COM, verified by reading the disk.
- uitest.py covers the error path (compile fails -> editor opens on the error).
- This covers the success path, which is the one that produces an artifact:
- 1. W loads a work file (LoadWorkFile ends with a Pause: needs a filler key)
- 2. O opens the Options submenu and C selects Destination = .COM
- 3. ESC returns to the main menu
- 4. C compiles, and the shell reports the .COM it wrote
- 5. The .COM is then INDEPENDENTLY checked on disk: the runtime's first
- bytes, a fully zero code/data gap, a size that covers the data area, and
- the program header words
- Step 5 is the point. "The shell said it wrote a file" is not evidence that
- the file is right, and the shell reports success from the same state that
- produced the bytes, so the check that matters happens outside the shell.
- The .COM is removed afterwards so the test can be re-run.
- Usage: comtest.py [fixture.pas]
- Passing a fixture that does NOT compile is expected to be RED - that is the
- test being non-vacuous, not a failure of the shell. It was checked that way
- (t14_types.pas, which raises ENoLib for 'array' at its point of use).
- """
- import os
- import sys
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
- from ptyharness import SHELL_DIR, Screen, drain, reap, send, spawn, status_str, visible
- FIXTURE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(
- os.path.dirname(os.path.abspath(__file__)), "fixtures", "t26_str_mixed_args.pas")
- COM = os.path.splitext(FIXTURE)[0] + ".COM"
- # Restated here on purpose - the checker must not ask the code under test.
- # ENT_SZ and HDR_SZ are the layout, and the load bias below is a decision this
- # checker made on its own account; those stay written down.
- #
- # The RUNTIME'S SIZE is not restated, and that is the correction. It used to
- # be a literal (385, then 391) beside a comment saying it tracks
- # Runtime.RT_Size(). It did not: the runtime is 432 bytes, so this checker
- # read the program header 41 bytes early, out of the middle of the code, and
- # reported a .COM full of nonsense - hdrFlag=61579 - as a compiler fault. A
- # duplicated constant that has drifted is not an independent check, it is a
- # second source of truth that lies, and it lies in a way that looks like the
- # thing under test is broken. The size is now MEASURED, by the same
- # self-consistent-header argument documented in find_header below.
- ENT_SZ = 3 # E9 lo hi, the entry jump
- HDR_SZ = 16 # 5 header words + 3 buffer words
- # RTSZ, PROLOG and DATAB are derived per .COM from find_header(d).
- # The image starts with a three-byte JMP - see Compiler.Inittur. A .COM is
- # entered at file offset 0, so before that jump existed this checker ASSERTED
- # that the runtime was at offset 0, which was precisely the bug: every .COM
- # began by executing initmem with whatever the loader left in AX. A checker
- # that pins a wrong invariant is worse than none, because it makes the wrong
- # thing look tested.
- # The load bias. A DOS .COM's first byte is at CS:0100 and CS = DS, so an
- # image offset K is at DS:(K + 0100h); every absolute address the image
- # contains has to carry it, or it points 0100h low and - since the code region
- # and the runtime are all below the bias - almost always lands inside the
- # runtime instead of inside the data. Relative encodings must not carry it.
- # Restated, not asked of the code under test. See Runtime.LoadBias.
- LOAD_BIAS = 0x100
- HEAD = "8B F0 8B 54 04 8B 4C 06" # MOV SI,AX / MOV DX,[SI+4] / MOV CX,[SI+6]
- HDR_DS_WORD = 4 # header word holding the data base
- HDR_HEAP_WORD = 6 # header word holding the data end
- assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
- [HDR_DS_WORD, HDR_HEAP_WORD], \
- "initmem no longer reads the two header words this checker verifies"
- def find_header(d):
- """Return the image offset of the program header, or None.
- The header is eight words, and hdrDS ties it to its OWN offset: the data
- base is header offset + 1000h, and hdrDS is the data base with the load
- bias added, so header offset = hdrDS - 1000h - bias. That makes the
- offset recoverable from the file with no remembered runtime size, which is
- the whole point - see the note on RT_SZ above.
- A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
- equation, hdrHeap is above hdrDS, and hdrCS leaves room for the header
- itself. initmem is the only code in the image that reads the header, so
- its bytes are pinned at ENT_SZ and a match that also has them is not a
- coincidence in the code stream.
- Measuring is not the same as asking the compiler: this reads the emitted
- file, so it cannot be satisfied by the code under test agreeing with
- itself. tests/check_runtime.py is where the runtime's size is pinned on
- purpose, and tests/run_com_tests.sh measures and prints it on every run.
- """
- head = bytes(int(x, 16) for x in HEAD.split())
- if d[ENT_SZ:ENT_SZ + len(head)] != head:
- return None
- for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
- w = (lambda b: int.from_bytes(d[off + b:off + b + 2], "little"))
- if w(0) != 1:
- continue
- if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS:
- continue
- if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD):
- continue
- if w(2) - LOAD_BIAS < off + HDR_SZ:
- continue
- return off
- return None
- def check_com(path, expected_src_len):
- """Return a list of problems (empty = the .COM is correct)."""
- errs = []
- if not os.path.exists(path):
- return ["no .COM file was written"]
- d = open(path, "rb").read()
- # Everything below is in terms of where the header actually is in THIS
- # file, measured, rather than where a literal once said it should be.
- hdr_off = find_header(d)
- if hdr_off is None:
- return ["no program header found: the layout this checker knows how to "
- "look for is not the one in the file, so every offset below "
- "would be a guess - reporting the guesses individually would "
- "be worse than saying so once"]
- RTSZ = hdr_off
- PROLOG = hdr_off + HDR_SZ
- DATAB = hdr_off + 0x1000
- # The entry jump. This is the only assertion in the project that can see
- # where execution STARTS, because it is the only one that cares. It has
- # caught three real bugs in these same three bytes: no jump at all; a jump
- # to `pc` (the END of the code, in the zero gap); and a jump to RTSZ,
- # which is the program HEADER - data, which the CPU then decodes as
- # instructions. That last one is why the target is PROLOG and not RTSZ:
- # whether it works depends on how the header happens to decode, so
- # writeln('hi') slid through it unharmed while t07 hung on a LOCK-prefixed
- # ADD. Every other check here reads bytes at an offset the compiler chose
- # for itself.
- if len(d) >= ENT_SZ:
- if d[0] != 0xE9:
- errs.append("byte 0 is %02X, not the E9 of the entry jump" % d[0])
- want_rel = PROLOG - ENT_SZ
- got_rel = int.from_bytes(d[1:ENT_SZ], "little")
- if got_rel != want_rel:
- errs.append("entry jump rel16=%d, want %d; lands on image offset "
- "%d, want %d (the first instruction, %d bytes past the "
- "header - not the header at %d)"
- % (got_rel, want_rel, ENT_SZ + got_rel, PROLOG,
- HDR_SZ, RTSZ))
- if d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(" ").upper() != HEAD:
- errs.append("runtime not at offset %d: bytes there %s, want %s"
- % (ENT_SZ,
- d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(" ").upper(),
- HEAD))
- if len(d) < DATAB:
- errs.append("file is %d bytes, shorter than the data base %d - the "
- "globals would be outside the file" % (len(d), DATAB))
- # the runtime must be followed immediately by the program header,
- # whose first word is the "image is valid" flag
- if len(d) >= RTSZ + 2:
- flag = int.from_bytes(d[RTSZ:RTSZ + 2], "little")
- if flag != 1:
- errs.append("hdrFlag=%d at the program header, want 1" % flag)
- if len(d) >= RTSZ + 10:
- ds = int.from_bytes(d[RTSZ + HDR_DS_WORD:RTSZ + HDR_DS_WORD + 2], "little")
- heap = int.from_bytes(d[RTSZ + HDR_HEAP_WORD:RTSZ + HDR_HEAP_WORD + 2], "little")
- if ds != DATAB + LOAD_BIAS:
- errs.append("hdrDS=%d, want %d (= data base %d + load bias %d)"
- % (ds, DATAB + LOAD_BIAS, DATAB, LOAD_BIAS))
- if heap < ds:
- errs.append("hdrHeap=%d < hdrDS=%d" % (heap, ds))
- if [d[ENT_SZ + 4], d[ENT_SZ + 7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
- errs.append("initmem reads header words +%d/+%d, but the data base "
- "and data end are at +%d/+%d"
- % (d[ENT_SZ + 4], d[ENT_SZ + 7],
- HDR_DS_WORD, HDR_HEAP_WORD))
- # The gap between the end of the code and the data area must be all zero.
- # hdrCS is a SEGMENT offset, like every other offset in the header, so
- # LOAD_BIAS comes back off before it is used to index the file.
- cs = (int.from_bytes(d[RTSZ + 2:RTSZ + 4], "little") - LOAD_BIAS) \
- if len(d) >= RTSZ + 4 else 0
- if cs and cs < DATAB:
- nz = sum(1 for b in d[cs:DATAB] if b)
- if nz:
- errs.append("%d non-zero bytes in the code/data gap" % nz)
- return errs
- def main():
- if os.path.exists(COM):
- os.unlink(COM)
- pid, fd = spawn()
- checks = []
- try:
- drain(fd, quiet=0.6)
- send(fd, b"w")
- send(fd, FIXTURE.encode())
- send(fd, b"\r", quiet=0.8)
- send(fd, b" ", quiet=0.5) # clears LoadWorkFile's Pause
- # Options submenu -> Destination = .COM.
- # The submenu is left with Q (that is its Quit key, as in the
- # original's optmenu); ESC is swallowed by its REPEAT loop.
- send(fd, b"o", quiet=0.8)
- opt = visible(send(fd, b"c", quiet=0.5))
- checks.append(("Options submenu opened", "Compile" in opt))
- checks.append(("selecting C set Destination = COM", "COM" in opt))
- after = visible(send(fd, b"q", quiet=0.8))
- checks.append(("Q left the submenu for the main menu", "Main file" in after))
- comp = visible(send(fd, b"c", quiet=2.0))
- checks.append(("compile reported success", "Compiled OK" in comp))
- checks.append(("shell reported the Destination it used",
- "Destination" in comp))
- checks.append(("shell named the .COM it wrote",
- os.path.basename(COM) in comp))
- send(fd, b"\x1b", quiet=0.6)
- send(fd, b"q", quiet=0.8)
- finally:
- status = reap(fd, pid)
- checks.append(("shell exited cleanly (status 0)", os.WIFEXITED(status)
- and os.WEXITSTATUS(status) == 0))
- # now verify the artifact independently
- errs = check_com(COM, len(open(FIXTURE, "rb").read()))
- checks.append((".COM exists on disk and passes the independent check"
- if not errs else ".COM check: " + "; ".join(errs), not errs))
- checks.append((".COM was written next to the source, not in the shell's cwd",
- os.path.dirname(os.path.abspath(COM)) == os.path.dirname(FIXTURE)))
- print("UI TEST: %s" % os.path.basename(FIXTURE))
- print("-" * 60)
- ok = True
- for name, passed in checks:
- print("%-58s %s" % (name[:58], "PASS" if passed else "FAIL"))
- ok = ok and passed
- print("-" * 60)
- print("child: %s" % status_str(status))
- print("RESULT: %s" % ("ALL PASS" if ok else "FAILURES PRESENT"))
- if os.path.exists(COM):
- print("(removed %s)" % COM)
- os.unlink(COM)
- return 0 if ok else 1
- if __name__ == "__main__":
- sys.exit(main())
|