.html`. Nothing is cached. `transformHtml(html, file)`, when given, rewrites each `.html` file before it is sent: `serve.mjs` adds its reload script with it to every page but `book.html`. Logs a 404 or a 500 to stderr. |
+
### `command-line.mjs`
| Symbol | Signature | Description |
diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md
index 97460cfd4..30aaf64c4 100644
--- a/docs/Documentation/Tools.md
+++ b/docs/Documentation/Tools.md
@@ -8,14 +8,14 @@ permalink: /Documentation/Development/Tools
# Tools and Scripts
{: .no_toc }
-One-line-per-tool reference for every executable in the documentation repository: the eight Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2**. So is a crash. So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`.
+One-line-per-tool reference for every executable in the documentation repository: the nine Windows batch wrappers at the repository root, the Node and Python scripts under `scripts/` (cross-platform except for [`tbbuild.mjs`](#tbbuild), which drives the twinBASIC IDE), the `tbdocs` orchestrator and its CLI flags, and the PDF render driver. If you are looking for the day-to-day workflow rather than a cheat sheet, the [Building and Deployment](Building) page is the gentler read; if you are modifying the build pipeline itself, the [tbdocs Internals](Builder) page goes one level deeper. Every Node tool answers `--help` or `-h` by printing its usage to standard output and exiting 0, without doing any of its work. A command line a tool cannot use is refused the same way by every Node tool: an unknown flag, a flag without its value or with an empty one, and an unexpected argument are reported on standard error and exit **2**. So is a crash. So is a value a tool cannot use, before it does any work: a number that is not one or is out of range (a fraction where a whole number is needed), a regular expression that does not compile, a URL that is not an absolute `http` or `https` one, a date that is not ISO 8601, a value outside a fixed set, and options that exclude each other. A tool that reads its command line through `lib/cli.mjs` and takes search terms, file names or folder names takes one that starts with a dash after `--`.
* TOC goes here
{:toc}
## Batch wrappers at the repository root
{: #batch-wrappers }
-All eight sit at the repository root, beside `package.json` --- not under `docs/`. Each uses `@pushd "%~dp0"` to run from that root regardless of where it is invoked from, and each entry below gives the POSIX equivalent of what it runs. Those equivalents have no `pushd` in front of them, so **run them from the repository root** --- `tbdocs`'s `--src docs`, [`check_publish_policy.mjs`](#check-publish-policy)'s default source root, and every path handed to [`render-book.mjs`](#bookrender-bookmjs) are all resolved against the working directory. `examples.bat`, `addin-test.bat` and `ide-test.bat` are the exceptions to "each entry below gives the POSIX equivalent": they need a twinBASIC install and drive the IDE, so they are Windows-only, and neither is part of the site build. Four other tools are Windows-specific for the same reason and are likewise not part of it: [`scripts/tbbuild.mjs`](#tbbuild) and [`scripts/tbrun.mjs`](#tbrun), which drive the twinBASIC IDE, and [`census_attributes.mjs`](#census-attributes) and [`build_package_api.mjs`](#build-package-api), which run the twinBASIC compiler's `export` verb --- though those two are cross-platform when given an already-exported tree with `--exported`. Nothing else in the repository is: `tbdocs` and every gate in both wrappers is a Node script, and CI runs all of them on `ubuntu-latest` except [`check_tree_fresh.mjs`](#check-tree-fresh), which guards against a failure mode CI cannot have.
+All nine sit at the repository root, beside `package.json` --- not under `docs/`. Each uses `@pushd "%~dp0"` to run from that root regardless of where it is invoked from, and each entry below gives the POSIX equivalent of what it runs. Those equivalents have no `pushd` in front of them, so **run them from the repository root** --- `tbdocs`'s `--src docs`, [`check_publish_policy.mjs`](#check-publish-policy)'s default source root, and every path handed to [`render-book.mjs`](#bookrender-bookmjs) are all resolved against the working directory. `examples.bat`, `addin-test.bat`, `ide-test.bat` and `try-help-addin.bat` are the exceptions to "each entry below gives the POSIX equivalent": they need a twinBASIC install and drive the IDE, so they are Windows-only, and none is part of the site build. Four other tools are Windows-specific for the same reason and are likewise not part of it: [`scripts/tbbuild.mjs`](#tbbuild) and [`scripts/tbrun.mjs`](#tbrun), which drive the twinBASIC IDE, and [`census_attributes.mjs`](#census-attributes) and [`build_package_api.mjs`](#build-package-api), which unpack the packages of a twinBASIC install --- though those two are cross-platform when given an already-exported tree with `--exported`. Nothing else in the repository is: `tbdocs` and every gate in both wrappers is a Node script, and CI runs all of them on `ubuntu-latest` except [`check_tree_fresh.mjs`](#check-tree-fresh), which guards against a failure mode CI cannot have.
### build.bat
@@ -71,7 +71,7 @@ One of the four does not mean the same thing locally as it does in CI, on any pl
test.bat
-The tests the toolchain has to pass. Twenty steps, each stopping the run if it fails:
+The tests the toolchain has to pass. Twenty-one steps, each stopping the run if it fails:
1. [`scripts/check_publish_policy.mjs`](#check-publish-policy) --- verifies the publish allowlist still refuses the types it is meant to. Needs neither a browser nor a built tree, so it goes first.
2. [`scripts/check_gate_lists.mjs`](#check-gate-lists) --- verifies the two gate lists on this page still match the wrappers that run them.
@@ -82,17 +82,18 @@ The tests the toolchain has to pass. Twenty steps, each stopping the run if it f
7. [`test/strftime.test.mjs`](#strftime-test) --- unit tests for the footer's date formatter, which no build calls.
8. [`test/png.test.mjs`](#png-test) --- unit tests for the pictures of a bug reproducer: PNG decoding, comparing and the side-by-side image, the files behind `images` and `expect.imagesDiffer`, and the refusals of `bug_repro.mjs` over fixtures.
9. [`test/example-batches.test.mjs`](#example-batches-test) --- runs `check_examples.mjs`'s probes, which test how samples are batched and how a crashed batch is cut down, without an IDE.
-10. [`scripts/check_regex_safety.mjs`](#check-regex-safety) --- refuses a regex that can backtrack exponentially, written as a literal or built from constants.
-11. [`scripts/check_code_regions.mjs`](#check-code-regions) --- verifies no pre-render rewrite alters the contents of a code fence or code span, that the rewrites over rendered HTML leave a raw `` or `` alone, and that `lib/markdown.mjs` and `lib/frontmatter.mjs` pass their probes.
-12. [`scripts/check_page_baseline.mjs`](#check-page-baseline) --- verifies the page-count drift guard still refuses a fall.
-13. [`scripts/check_book_coverage.mjs`](#check-book-coverage) --- verifies the build still warns about a page `docs/_book.yml` does not mention.
-14. [`scripts/check_symbol_index.mjs`](#check-symbol-index) --- verifies the symbol index still places each kind of symbol, and its drift guard still refuses a lost URL.
-15. [`scripts/check_twin_parsers.mjs`](#check-twin-parsers) --- verifies the scanners of twinBASIC source and of the attribute reference still read the shapes each once misread.
-16. [`scripts/check_attribute_sweep.mjs`](#check-attribute-sweep) --- verifies the logic of the attribute sweep: its site skeletons, how it reads a probe's diagnostics, how it batches probes, and how it compares the answers with `Attributes.md`.
-17. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors.
-18. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, patch the members of pdf-lib it lists, and run.
-19. [`scripts/check_impexp_parity.mjs`](#check-impexp-parity) --- verifies the two editions of the impexp tool pass the same built-in tests, and exit, print and write the same for one sequence of commands. Without Python it reports itself skipped and passes, except in CI.
-20. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values.
+10. [`test/ports.test.mjs`](#ports-test) --- unit tests for how the harness claims the DevTools ports its IDEs use, without an IDE.
+11. [`scripts/check_regex_safety.mjs`](#check-regex-safety) --- refuses a regex that can backtrack exponentially, written as a literal or built from constants.
+12. [`scripts/check_code_regions.mjs`](#check-code-regions) --- verifies no pre-render rewrite alters the contents of a code fence or code span, that the rewrites over rendered HTML leave a raw `` or `` alone, and that `lib/markdown.mjs` and `lib/frontmatter.mjs` pass their probes.
+13. [`scripts/check_page_baseline.mjs`](#check-page-baseline) --- verifies the page-count drift guard still refuses a fall.
+14. [`scripts/check_book_coverage.mjs`](#check-book-coverage) --- verifies the build still warns about a page `docs/_book.yml` does not mention.
+15. [`scripts/check_symbol_index.mjs`](#check-symbol-index) --- verifies the symbol index still places each kind of symbol, and its drift guard still refuses a lost URL.
+16. [`scripts/check_twin_parsers.mjs`](#check-twin-parsers) --- verifies the scanners of twinBASIC source and of the attribute reference still read the shapes each once misread.
+17. [`scripts/check_attribute_sweep.mjs`](#check-attribute-sweep) --- verifies the logic of the attribute sweep: its site skeletons, how it reads a probe's diagnostics, how it batches probes, and how it compares the answers with `Attributes.md`.
+18. [`scripts/check_cli.mjs`](#check-cli) --- verifies `lib/cli.mjs`, the command-line parser, and each tool's recorded command-line errors.
+19. [`scripts/check_pdf_shims_equiv.mjs`](#check-pdf-shims-equiv) --- verifies the book's pdf-lib shims write what stock pdf-lib writes, patch the members of pdf-lib it lists, and run.
+20. [`scripts/check_impexp_parity.mjs`](#check-impexp-parity) --- verifies the two editions of the impexp tool pass the same built-in tests, and exit, print and write the same for one sequence of commands. Without Python it reports itself skipped and passes, except in CI.
+21. [`scripts/check_axe_patch_equiv.mjs`](#check-axe-patch-equiv) --- verifies the vendored axe source patch still produces identical colour values.
POSIX:
@@ -105,6 +106,7 @@ POSIX:
&& node --test test/strftime.test.mjs \
&& node --test test/png.test.mjs \
&& node --test test/example-batches.test.mjs \
+ && node --test test/ports.test.mjs \
&& node scripts/check_regex_safety.mjs \
&& node scripts/check_code_regions.mjs \
&& node scripts/check_page_baseline.mjs \
@@ -119,7 +121,7 @@ POSIX:
Exit codes: **0** every step passed; otherwise the code of the step that stopped the run, as that step's entry gives it.
-**Seventeen of the twenty cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all twenty unconditionally, so skipping it locally cannot let a tooling regression reach `staging`.
+**Eighteen of the twenty-one cannot be affected by an edit confined to `docs/`**, which is why they are separate from `check.bat`. Run this one when the change touches `builder/`, `scripts/`, `lib/`, `book/`, `eval/`, `wisdom/` or `test/`, the site's scripts in `docs/assets/js/`, a wrapper, or a workflow. Both CI workflows run all twenty-one unconditionally, so skipping it locally cannot let a tooling regression reach `staging`.
The three exceptions are [`check_code_regions.mjs`](#check-code-regions), [`check_gate_lists.mjs`](#check-gate-lists), which reads this page, and [`check_lint.mjs`](#check-lint), which lints the site's scripts in `docs/assets/js/`. The first is worth knowing in detail. Its corpus sweep tokenises every markdown file under `docs/`, so a page that provokes a rewrite into altering a code region fails it. Its fixed probes are a different matter: they run against their own sources whatever the tree holds, and they cover the *mirror* fault, where a rewrite silently stops firing. The sweep cannot see that one --- text the rewrite skipped is stashed and restored unchanged, so every region still matches. Add a page with an unusual code construct and run `test.bat`, but read the built page too.
@@ -220,6 +222,19 @@ Tests the twinBASIC IDE itself by machine: it opens a project in an IDE, operate
Exit codes: those of [`ide_test.mjs`](#ide-test), returned as they are: **0** every lane passed and the registry is as it was found, **1** a lane failed, **2** the harness failed, **3** the registry or a work folder was not put back.
+### try-help-addin.bat
+{: #try-help-addinbat }
+
+ try-help-addin.bat [flags]
+
+One invocation of [`try_help_addin.mjs`](#try-help-addin), with every flag passed straight through:
+
+ node scripts/try_help_addin.mjs [flags]
+
+Opens an IDE on your desktop with the help add-in built and loaded, to try it by hand, and puts the registry back once the IDE is closed. **It is not one of the gates**: it needs a twinBASIC install and Windows.
+
+Exit codes: those of [`try_help_addin.mjs`](#try-help-addin), returned as they are: **0** the IDE was closed and the registry is as it was found, **1** the add-in did not build or the project does not compile, **2** the tool could not run, **3** the registry or the work folder was not put back.
+
## CLI tools
### tbdocs --- node builder/tbdocs.mjs
@@ -603,6 +618,15 @@ Runs the probes of [`check_examples.mjs`](#check-examples) under Node's own test
Exit codes: **0** every test passed, **1** a test failed.
+### ports.test.mjs
+{: #ports-test }
+
+ node --test test/ports.test.mjs
+
+Unit tests for `scripts/lib/tb-ports.mjs`, which claims the DevTools ports that [`addin_test.mjs`](#addin-test), [`ide_test.mjs`](#ide-test) and [`try_help_addin.mjs`](#try-help-addin) start their IDEs on. A port is claimed with a lock file in `tb-ports` under the system temp directory before it is checked, and runs claim one at a time, so two runs started together never get the same one. Four child processes claim three ports each at the same time, from a range where one port is in use, one has the lock of a process that has ended and one the lock of a live process, and each holds its ports until all have claimed. The test checks that the claims are disjoint, that they are exactly the free ports with the stale lock taken over, and that each lock is gone once its process exits. Two more tests ask for more free ports than the range holds, which is refused with the port found unlocked again, and claim past the unfinished claim of a run that ended. Starts no IDE. No browser, no built tree, about 2 s.
+
+Exit codes: **0** every test passed, **1** a test failed.
+
### check_page_baseline.mjs
{: #check-page-baseline }
@@ -998,7 +1022,7 @@ behind. That includes the target the IDE remembers for each project, which a `wi
writes. **A probe builds for the target `--arch` names**, whatever the IDE remembers, so a
kept IDE switched to `win64` does not make later runs on the same port build 64-bit.
-Exit codes: **0** the probe ran and its output was captured; **1** the project has compile errors (the diagnostics are printed); **2** a refused command line (a source folder that is missing or has no `Settings` file included), no IDE or compiler, an IDE that did not start, a compile that never settled, a build that failed after a clean compile, a probe that never ran or stopped at a procedure that failed code generation, an LLVM run on a Community or Personal licence, an `--exe` run with no exe built, or a crash; **3** no output: the console held none before the timeout, or the probe printed none after its last `Debug.Cls`; **4** the compiler crashed, or restarted twice, while compiling the project; **5** the probe ended before it returned, its output printed all the same; **6** under `--exe`, the exe exited with a code other than 0, or was still running after `--timeout` and was ended, its output and exit code printed all the same. A run that would exit 5 exits 5 whatever the exe did.
+Exit codes: **0** the probe ran and its output was captured; **1** the project has compile errors (the diagnostics are printed); **2** a refused command line (a source folder that is missing or has no `Settings` file included), no IDE, an IDE that did not start, a compile that never settled, a build that failed after a clean compile, a probe that never ran or stopped at a procedure that failed code generation, an LLVM run on a Community or Personal licence, an `--exe` run with no exe built, or a crash; **3** no output: the console held none before the timeout, or the probe printed none after its last `Debug.Cls`; **4** the compiler crashed, or restarted twice, while compiling the project; **5** the probe ended before it returned, its output printed all the same; **6** under `--exe`, the exe exited with a code other than 0, or was still running after `--timeout` and was ended, its output and exit code printed all the same. A run that would exit 5 exits 5 whatever the exe did.
### bug_repro.mjs
{: #bug-repro }
@@ -1024,7 +1048,7 @@ outside every gate and outside CI, and `verify` is run by a person only.
| Command | Effect |
|---|---|
| `new ""` | Creates `bugs//src/` from the console template under `test/example-projects/`, with the project named after the slug in PascalCase, a fresh project id, the description `Reproduces: ` and a `Startup` module holding an empty `Sub Main`. Also writes `bugs//repro.json` with `"mode": "manual"`. Refused, with exit 3, when `bugs/` exists. `--template ` starts from a folder of `test/repro-templates/` instead: its `Settings`, with the same four keys rewritten, and its `Sources/` as they are. `webview2-form` is a form holding one WebView2 control, which opens `about:blank` when the control is ready and closes when that navigation completes, shown modally by `Sub Main`. `--with-vb6` also creates `bugs//vb6/` from the VB6 template in `test/repro-templates/vb6/`: `Probe.vbp` and `Module1.bas`, whose `Sub Main` opens `out.txt` beside the exe, prints one line under an error handler and closes. `--with-images` also copies `PngDump.twin` from `test/repro-templates/png/` into `src/Sources/` and, with `--with-vb6`, `PngDump.bas` into `vb6/` with its `Module=PngDump; PngDump.bas` line in `Probe.vbp`, and writes `"images": ["main"]` into `repro.json`. |
-| `pack ` | Runs `node scripts/impexp.mjs import` on `src/`, to `.twinproj`, and then writes `.zip` holding that file and the files `repro.json`'s `attach` names. The zip is written by the script itself, so neither PowerShell nor 7-Zip is needed. An `impexp` exit of 0 or 6 counts as a pack, and its output is printed. When `bugs//vb6/` exists, it also writes `-vb6.zip`, holding the source files of that folder (`.vbp`, `.bas`, `.cls`, `.frm`, `.frx`, `.ctl` and `.ctx`) and nothing else, so an exe or an output left there is not zipped; the files left out are named. A `vb6/` with no `Probe.vbp`, or whose sources call `MsgBox` or `InputBox`, is refused. The zip also holds every file of `images/`, and `pack` prints the path of each `-compare.png`, the pictures to put in the issue. |
+| `pack ` | Packs `src/` into `.twinproj` with [`impexp.mjs`](#impexp), called directly, and prints the lines that `impexp.mjs import` prints. It then writes `.zip` holding that file and the files `repro.json`'s `attach` names. The zip is written by the script itself, so neither PowerShell nor 7-Zip is needed. When `bugs//vb6/` exists, it also writes `-vb6.zip`, holding the source files of that folder (`.vbp`, `.bas`, `.cls`, `.frm`, `.frx`, `.ctl` and `.ctx`) and nothing else, so an exe or an output left there is not zipped; the files left out are named. A `vb6/` with no `Probe.vbp`, or whose sources call `MsgBox` or `InputBox`, is refused. The zip also holds every file of `images/`, and `pack` prints the path of each `-compare.png`, the pictures to put in the issue. |
| `compile ` | Packs, then compiles the project with `tbbuild --json` and prints its diagnostics. |
| `build ` | Packs, then compiles and builds it with `tbbuild --build`, or `--llvm` when that is given. A build that fails prints the build log and the failing line. |
| `run ` | Copies `src/` to `%TEMP%\bugrepro\\`, adds a `TbRunProbe` module whose `[RunAfterBuild]` Sub calls `Debug.Cls` and then `Main`, runs `tbrun` on the copy and prints what it captured. The Sub clears `WEBVIEW2_USER_DATA_FOLDER` and `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` while `Main` runs: the harness starts the IDE with both, and WebView2 lets them override what a WebView2 control in the project asks for, so the control would fail to start inside the IDE's process. Apart from the pictures below, the tree under `bugs/` is not changed. With `--exe` no probe module is added: `tbrun` runs `Sub Main` in the built exe. With `images` in `repro.json`, `run` also gives `tbrun` an empty folder in `BUGREPRO_IMAGES`, which the IDE and, with `--exe`, the exe inherit, and when the run finishes keeps each picture as `images/-tb.png`. A picture the probe did not write is a failure that names it, exit 9. |
@@ -1102,7 +1126,7 @@ the bug unless `expect.exit` names it. Reproducers run one at a time. `--jobs N`
once, each in the IDE on its own port, from `--port` up. `verify` tidies the IDE's registry
entries once for all of them, as [`check_examples.mjs`](#check-examples) does.
-Exit codes: **0** done --- a project that compiled, built or ran as it should, or, for `verify`, every reproducer that can be run on its own still reproduces; **1** a finding: the project has errors, or its build failed after a clean compile, or, for `vb6`, VB6 refused the project, or, for `verify`, at least one reproducer no longer reproduces; **2** a refused command line, a `repro.json` that is not valid, no IDE, a project that could not be packed, a harness that failed, or a crash; for `vb6`, no VB6, a reproducer with no `vb6/` folder, a project that has no `Probe.vbp` or calls `MsgBox` or `InputBox`, or VB6 failing to build it; for `verify`, a lane's harness failed; for `file`, an entry that is missing, ambiguous or marked unreadably, or a `bugs/filed/` already there, with nothing changed; **3** `new` found `bugs/` or `bugs/filed/` already there; **4** the compile never settled; **5** the project crashes the compiler; **6** `run`: the probe printed nothing; for `vb6`, the exe wrote no `out.txt`, or an empty one; **7** `run`: the probe ended before it returned; **8** `run --exe`, and `vb6`: the exe exited with a code other than 0, or was still running after `--timeout`; **9** `run` and `vb6`: a picture that `images` names was not written, or could not be read.
+Exit codes: **0** done --- a project that compiled, built or ran as it should, or, for `verify`, every reproducer that can be run on its own still reproduces; **1** a finding: the project has errors, or its build failed after a clean compile, or, for `vb6`, VB6 refused the project, or, for `verify`, at least one reproducer no longer reproduces; **2** a refused command line, a `repro.json` that is not valid, no IDE, a project that could not be packed, a harness that failed, or a crash; for `vb6`, no VB6, a reproducer with no `vb6/` folder, a project that has no `Probe.vbp` or calls `MsgBox` or `InputBox`, or VB6 failing to build it; for `verify`, a lane's harness failed; for `file`, an entry that is missing, ambiguous or marked unreadably, or a `bugs/filed/` already there, with nothing changed; **3** `new` found `bugs/` or `bugs/filed/` already there; **4** the compile never settled; **5** the project crashes the compiler; **6** `run`: the probe printed nothing; for `vb6`, the exe wrote no `out.txt`, or an empty one; **7** `run`: the probe ended before it returned; **8** `run --exe`, and `vb6`: the exe exited with a code other than 0, or was still running after `--timeout`, or, with `vb6`, the Application event log records that it faulted (a VB6 exe that dies of an access violation exits with code 0); **9** `run` and `vb6`: a picture that `images` names was not written, or could not be read.
### vb6run.mjs
{: #vb6run }
@@ -1119,11 +1143,11 @@ Builds and runs Visual Basic 6 code, so that what a documented sample prints in
| Mode | Effect |
|---|---|
| `` | A `.bas` module, or a text file of bare statements; `-` reads statements from standard input. A file that defines `Sub Main` is a whole module: it is built as written, its `Sub Main` is renamed so that the generated `Main` can start it, and it keeps its `Attribute VB_Name` line or is given one. Any other file is the body of a generated procedure. What the sample printed goes to standard output. A compile error is reported to standard error as VB6 reports it, with the line given as a line of the file, and a run-time error as `[vb6] error : `. |
-| `--docs` | Reads the documentation's `check_run` fences with the reader `check_examples.mjs` uses and builds each as a module of its own in a VB6 project; the fences that need no other fence share one project. The run fences of a `projname=` group are built in a project of their own, since class and module names collide between groups, together with the other fences of the group, each of which is a file. A `slot=file` fence is translated into VB6 components: every `Class ` block becomes a class module and every `Module ` block a standard module, `Public`, `Private` or `Friend` before the keyword being accepted, and anything outside those blocks (`Declare`, `Type`, `Enum`, `Const`, procedures) becomes one more standard module. Nothing else is translated. A construct VB6 has no form for, such as an `Interface`, a `CoClass`, a generic or an attribute line, stays where it is and VB6 refuses it, so each run fence of a group whose files do not build is `not VB6`, with the first error at its line in the page. Each of the run fences ends as `same` (VB6 prints what the page says twinBASIC prints), `differs` (the lines that differ, the page against VB6, with the page path and line), `not VB6` (VB6 refuses to compile it, with its first error; most twinBASIC syntax ends here, and it is informational), `error` (a run-time error, or it did not return), or `refused` (the sample cannot be run, for the reasons `check_run` gives). A fence that says `project=form` is built with a blank `Form1.frm`, which VB6 refuses to build with Unattended Execution, so such a project is built without it and its exe runs on a private desktop; each sample's forms are unloaded when it ends. On a `Declare` statement, `PtrSafe` is dropped and `LongPtr` is read as `Long`, because VB6 knows neither. A compile error stops VB6 at the first module that fails, so that module is dropped and the project is built again until it builds. A summary line gives the count of each. `--only ` keeps the pages whose path under `docs/` matches. |
+| `--docs` | Reads the documentation's `check_run` fences with the reader `check_examples.mjs` uses and builds each as a module of its own in a VB6 project; the fences that need no other fence share one project. The run fences of a `projname=` group are built in a project of their own, since class and module names collide between groups, together with the other fences of the group, each of which is a file. A `slot=file` fence is translated into VB6 components: every `Class ` block becomes a class module and every `Module ` block a standard module, `Public`, `Private` or `Friend` before the keyword being accepted, and anything outside those blocks (`Declare`, `Type`, `Enum`, `Const`, procedures) becomes one more standard module. Nothing else is translated. A construct VB6 has no form for, such as an `Interface`, a `CoClass`, a generic or an attribute line, stays where it is and VB6 refuses it, so each run fence of a group whose files do not build is `not VB6`, with the first error at its line in the page. Each of the run fences ends as `same` (VB6 prints what the page says twinBASIC prints), `differs` (the lines that differ, the page against VB6, with the page path and line), `not VB6` (VB6 refuses to compile it, with its first error; most twinBASIC syntax ends here, and it is informational), `error` (a run-time error, the exe ended during it, or it did not return), or `refused` (the sample cannot be run, for the reasons `check_run` gives). A fence that says `project=form` is built with a blank `Form1.frm`, which VB6 refuses to build with Unattended Execution, so such a project is built without it and its exe runs on a private desktop; each sample's forms are unloaded when it ends. On a `Declare` statement, `PtrSafe` is dropped and `LongPtr` is read as `Long`, because VB6 knows neither. A compile error stops VB6 at the first module that fails, so that module is dropped and the project is built again until it builds. A summary line gives the count of each. `--only ` keeps the pages whose path under `docs/` matches. |
Other options: `--timeout S` is the time limit, in seconds, for each run of the built exe (default 30), and `--json` prints one JSON object in place of the text.
-Exit codes: **0** the sample ran, or, with `--docs`, no fence differs and none raised an error; **1** a VB6 compile error, a run-time error or a sample that did not return, or, with `--docs`, at least one fence that differs or raised an error; **2** the harness could not run --- a refused command line, a file that is missing, a sample that is refused, no VB6, VB6 failing to build, or a crash.
+Exit codes: **0** the sample ran, or, with `--docs`, no fence differs and none raised an error; **1** a VB6 compile error, a run-time error, a sample during which the exe ended (a VB6 exe that dies of an access violation can exit with code 0, so the exe ending before the time limit in the middle of a sample is what shows it; the fault the Application event log records is named when there is one) or a sample that did not return, or, with `--docs`, at least one fence that differs or raised an error; **2** the harness could not run --- a refused command line, a file that is missing, a sample that is refused, no VB6, VB6 failing to build, or a crash.
### addin_test.mjs
{: #addin-test }
@@ -1140,14 +1164,14 @@ never loads into your own IDE, and two lanes never share one. The scenario build
it tests into its copy, opens a project, and operates the IDE: it clicks, presses keys, types,
and reads the add-ins' tool windows, message boxes, notifications, the code editor and the
Debug Console. Two scenarios operate the IDE's own sample add-ins, Sample 10 and Sample 15
-(Global Search), end to end. The others are probes, and take what they need from
+(Global Search), end to end. The others but the last are probes, and take what they need from
`test/addin/probes/`. Two build add-ins of their own and check what the tbIDE pages say about
the IDE: which keyboard shortcuts fire, for the
[KeyboardShortcuts](../../tB/Packages/tbIDE/KeyboardShortcuts) page, and what a tool window
does with HTML and with a web page in an `iframe`, for the
[HtmlElement](../../tB/Packages/tbIDE/HtmlElement) page and its neighbours. The panes lane
serves the pages its frame shows from a server of its own on `localhost`. Three more answer
-questions the planned help add-in rests on: what the compiler says about the name under the
+questions the help add-in rests on: what the compiler says about the name under the
cursor, which files the IDE's own web server serves from its `ide` folder, and which folders
the compiler loads add-ins from. The last three check what the [Add Ins](../../tB/IDE/AddIns/)
and [tbIDE package](../../tB/Packages/tbIDE/) pages say about loading: which folder each
@@ -1155,12 +1179,14 @@ build target loads, what a compiler restart does to a loaded add-in, and which e
names the IDE accepts. They build add-ins for win64 as well as win32, restart the compiler,
and patch a built DLL's export name. One more checks that the environment variable the
runner sets, which keeps an add-in under test from opening a browser, reaches the add-in,
-also after a compiler restart. The eleven lanes take under two minutes together.
+also after a compiler restart. The last lane tests the help add-in in `add-in/`, which opens
+the page for the name under the cursor, with the copy of the symbol index committed in
+`add-in/Resources/SYMBOLS/`. The twelve lanes take about two minutes together.
| Flag | Effect |
|---|---|
| `--only ` | Run only the lanes whose name matches. A lane's name is its file's name without `.test.mjs`. |
-| `--port ` | Base DevTools port. Default 9560; the lanes get *n*, *n*+1 and so on, and their work folders are keyed to their ports. A port another IDE holds is refused, as for `tbbuild`. |
+| `--port ` | Base DevTools port. Default 9560; the lanes get the first free ports from *n*, and their work folders are keyed to their ports. A port is free when nothing listens on it and no other run of these tools has claimed it: each run claims its ports with a lock file in `%TEMP% b-ports`, so two runs started together never share one. |
| `--jobs ` | Lanes at once. Default 2. |
| `--timeout ` | A lane still running after this long is ended and counted as failed. Default 600. |
| `--ide ` | The `twinBASIC.exe` to copy, found as for [`tbbuild.mjs`](#tbbuild). |
@@ -1205,7 +1231,7 @@ same exit codes: each file listed in `test/ide/lanes.mjs` is one **lane**, run b
[`ide-test.bat`](#ide-testbat) in a process of its own, with its own DevTools port, work
folder and private copy of the twinBASIC install. Run on its own, a scenario skips itself.
The two differences are the suite and the default base port, which is 9660 here, so that a
-run of each tool never takes the other's ports. A run whose `test/ide/lanes.mjs` lists no lane
+run of each tool starts on ports of its own. A run whose `test/ide/lanes.mjs` lists no lane
matching `--only` is refused, as for `addin_test.mjs`.
It leaves the registry as it found it, and checks, exactly as `addin_test.mjs` does. The rules
@@ -1214,6 +1240,30 @@ of that tool apply here unchanged: every IDE it starts has a private `APPDATA` a
Exit codes: **0** every lane passed, and the registry is as it was found; **1** a lane failed, or the run was interrupted; **2** the harness could not run: a refused command line, no IDE, no matching lane, a registry it could not record, or a crash after which the registry was put back; **3** the registry or a work folder was not put back (see the lines above), at the end of a run or after a crash, which wins over a 1 because the registry is what to repair.
+### try_help_addin.mjs
+{: #try-help-addin }
+
+ node scripts/try_help_addin.mjs [--project ] [--port N] [--ide ]
+
+Opens an IDE on your own desktop with the help add-in in `add-in/` built and loaded, to try it
+by hand, and waits until the IDE is closed. The IDE is set up as a lane of
+[`addin_test.mjs`](#addin-test) is: the add-in goes into a private copy of the twinBASIC install
+in the temp folder, never into the install's `addins\` or `%APPDATA%\twinBASIC\addins\`, and the
+IDE has a private `APPDATA`, so none of your own add-ins loads into it. `TB_ADDIN_TEST` is `1`,
+so *Open in browser* prints `open ` to the Debug Console and starts nothing. The pane's
+pages come from the built site, `docs/_site`, served on `localhost`, so run `build.bat` first.
+Unlike the lanes, the IDE is always on your desktop; the one that builds the add-in first runs on a
+private desktop, as a lane's does, so it never appears or takes the focus.
+
+`--project` names the exported project to open, by default `test/addin/helphost`, the help
+lane's host. It is opened as a staged copy, so edits made in the IDE are not kept. `--port` is
+where the search for the IDE's DevTools port starts, 9590 by default; it takes the first
+free one, as `addin_test.mjs` does. Closing the IDE, or Ctrl+C, puts back the IDE's
+registry entries and the add-in's saved settings (`tbDocsHelp`) as they were found, and deletes
+the copy.
+
+Exit codes: **0** the IDE was closed, and the registry is as it was found; **1** the add-in did not build, or the project does not compile; **2** the tool could not run: a refused command line, no IDE, no built site, a registry it could not record, or a crash; **3** the registry or the work folder was not put back (see the lines above).
+
### check_tb_registry.mjs
{: #check-tb-registry }
@@ -1229,8 +1279,9 @@ restored, and a second restore writes nothing. The recent list gets two more che
the IDE changes it on its own while a run's projects are on it: it fills a short list's empty
slots with copies of the last entry, and a full list loses its oldest entry for each project
a run opens. The copies must go and the lost entries come back. The build targets the IDE remembers are checked the same way: those under
-the run's folder go, and every other one stays, in its order and its exact text. So is the
-rule that a file association pointing into the temp folder when a run began --- at another
+the run's folder go, and every other one stays, in its order and its exact text. The IDE's
+theme, which an add-in scenario switches, comes back, and every other IDE option stays as it
+is. So is the rule that a file association pointing into the temp folder when a run began --- at another
run's private copy of the IDE --- is left as it is rather than put back. It also checks that
the module refuses to sweep outside the temp folder or restore a key near the root of the
registry. It deletes the scratch key when it ends.
@@ -1365,7 +1416,7 @@ dispatcher, and the reading of the run's markers. `tb-install.mjs` finds the IDE
compiler beside it, and is shared with the two IDE-driving tools so the three cannot come to
disagree about where an install is.
-Exit codes: **0** every marked sample compiles, or none is marked (`--report` always, and `--propose` when it found only unmarked samples that fail, which is advisory); **1** a marked sample does not compile, a marker is misused, a template does not compile, the compiler crashed on a project, `--build` or `--llvm` found a sample that fails the build, or a `check_run` sample raised an error, did not return or printed something other than the page says (the report names each); **2** the harness could not run: a refused command line, a failed self-test probe, no IDE or compiler, an unreadable `--report` file, a work folder it could not clear, an `--llvm` run on a Community or Personal licence, or a crash.
+Exit codes: **0** every marked sample compiles, or none is marked (`--report` always, and `--propose` when it found only unmarked samples that fail, which is advisory); **1** a marked sample does not compile, a marker is misused, a template does not compile, the compiler crashed on a project, `--build` or `--llvm` found a sample that fails the build, or a `check_run` sample raised an error, did not return or printed something other than the page says (the report names each); **2** the harness could not run: a refused command line, a failed self-test probe, no IDE, an unreadable `--report` file, a work folder it could not clear, an `--llvm` run on a Community or Personal licence, or a crash.
### gen_attribute_probes.mjs
{: #gen-attribute-probes }
@@ -1384,11 +1435,11 @@ Up to three trees come out, on two contracts that must not be mixed:
Keeping the two contracts in separate projects is what makes either build readable: red in `AttributeProbes` is a defect, red in `AttributeExplore` is a result.
-It also writes a key naming the `Attributes.md` line each probe came from, beside the tree rather than inside it --- anything inside gets packed into the `.twinproj` and turns up as a stray project file. Pack a tree into a project with the compiler's own `import` verb before building it:
+It also writes a key naming the `Attributes.md` line each probe came from, beside the tree rather than inside it --- anything inside gets packed into the `.twinproj` and turns up as a stray project file. Pack a tree into a project with [`impexp.mjs`](#impexp) before building it:
- twinBASIC_win32.exe import AttributeProbes.twinproj --overwrite
+ node scripts/impexp.mjs import AttributeProbes.twinproj --overwrite
-**That command's exit code is `0` after every failure it reports**, so a script that packs a tree and then builds it will happily compile the previous `.twinproj`. The one failure it does not report --- a tree holding an embedded package --- exits `999`. Test the last line of its output for `... DONE` instead; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the caveat in full and a batch-file form of the test. The standalone [`impexp.mjs`](#impexp) takes the same command, and its exit code does say whether it worked. Re-run the generator after editing `Attributes.md`.
+Its exit code says whether the pack worked. The compiler's own `import` verb takes the same command line, but **its exit code is `0` after every failure it reports**, so a script that packs a tree with it and then builds will happily compile the previous `.twinproj`. The one failure it does not report --- a tree holding an embedded package --- exits `999`. Test the last line of its output for `... DONE` instead; [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) has the caveat in full and a batch-file form of the test. Re-run the generator after editing `Attributes.md`.
Exit codes: **0** the probe project and the key were written, **2** a refused command line (no output directory included), or a crash.
@@ -1399,7 +1450,7 @@ Exit codes: **0** the probe project and the key were written, **2** a refused co
[--refresh] [--samples] [--attr ]
[--json] [--out ] [--dump-sites ] [--quiet]
-Reports, for every attribute the twinBASIC packages use, **which enclosing construct and which kind of declaration it decorates**. It exports each package of an IDE install with the compiler's own `export` verb, scans the `.twin` sources, and writes a Markdown or JSON report. No arguments are needed: it finds the newest `twinBASIC_IDE_BETA_*` the same way [`tbbuild.mjs`](#tbbuild) does, caches the export under the build number, and reuses it on later runs. It is not part of the site build and nothing calls it during one.
+Reports, for every attribute the twinBASIC packages use, **which enclosing construct and which kind of declaration it decorates**. It unpacks each package of an IDE install with [`impexp.mjs`](#impexp), called directly, scans the `.twin` sources, and writes a Markdown or JSON report. No arguments are needed: it finds the newest `twinBASIC_IDE_BETA_*` the same way [`tbbuild.mjs`](#tbbuild) does, caches the export under the build number, and reuses it on later runs. It is not part of the site build and nothing calls it during one.
Against BETA 995 that is 661 files, 9,713 attribute sites and 55 distinct attributes.
@@ -1421,7 +1472,7 @@ Grouping is by enclosing construct *and* declaration keyword, because the keywor
The report ends with what the scanner could not resolve, and **that section is expected to be empty**. A census that quietly buckets its own confusion publishes a wrong number with nothing to notice it by, so an unresolved site is reported as a scanner bug rather than absorbed. The scanner handles several things this corpus does that a simpler sweep gets wrong: attributes spanning lines (`[Description("..." & vbCrLf & _` accounts for 3.8% of all attribute lines), comma-separated lists, arguments containing commas, escaped identifiers that look exactly like attributes (`[_HiddenModule].Foo`, and Enum members genuinely named `[A4 Portrait]`), comments in four different positions, and block-tracking traps such as a UDT field called `Type As Long` or a module named `[_HiddenModule]`.
-Exit codes: **0** the report was produced, **2** a refused command line, no install, an install with no compiler or no package project, or a crash (a package that fails to export is left out of the census).
+Exit codes: **0** the report was produced, **2** a refused command line, no install, an install with no package project, or a crash (a package that fails to export is left out of the census).
### sweep_attributes.mjs
{: #sweep-attributes }
@@ -1481,7 +1532,7 @@ Exit codes: **0** the report was produced and its self-checks held; **1** a self
Standalone `.twinproj` / `.twinpack` unpacker and packer, with the compiler executable's own command line: the same six commands, the project file first, and `--overwrite` required to replace anything. `scripts/impexp.py` is the same tool, run as `python scripts/impexp.py ...`; the two editions print the same output and write byte-identical project files, which [`check_impexp_parity.mjs`](#check-impexp-parity) checks. Neither has dependencies; the Node edition needs Node 18+, the Python edition Python 3.6+. The exit code says what happened, so a caller need not read the output. `--self-test` needs nothing but the script, and adds a round trip of `indexer/sample.twinpack` when run from this repository.
-**Neither is build tooling.** They are published downloads: `_config.yml`'s `bundle_extra` copies both into `Features/Packages/downloads/`, and [Import/Export Tool](../../Features/Packages/Import-Export-Tool) offers them to readers as the two editions of one tool. That is why `impexp.py` is one of only two `.py` files in a repository whose tooling is otherwise all Node --- porting it would delete a deliberate offering rather than tidy anything up. The `bundle_extra` exemption is by exact path, so moving either file breaks the download; see [`check_publish_policy.mjs`](#check-publish-policy).
+**Neither is run by the site build.** They are published downloads: `_config.yml`'s `bundle_extra` copies both into `Features/Packages/downloads/`, and [Import/Export Tool](../../Features/Packages/Import-Export-Tool) offers them to readers as the two editions of one tool. That is why `impexp.py` is one of only two `.py` files in a repository whose tooling is otherwise all Node --- porting it would delete a deliberate offering rather than tidy anything up. The `bundle_extra` exemption is by exact path, so moving either file breaks the download; see [`check_publish_policy.mjs`](#check-publish-policy). The Node edition is also a library: `scripts/lib/tb-project.mjs` imports its `exportProject` and `importProject`, and the tools that unpack or pack a twinBASIC project call them directly, among them [`tbbuild.mjs`](#tbbuild), [`tbrun.mjs`](#tbrun), [`check_examples.mjs`](#check-examples), [`census_attributes.mjs`](#census-attributes) and [`bug_repro.mjs`](#bug-repro). The Python edition is only a download.
Exit codes: the table in [Import/Export Tool](../../Features/Packages/Import-Export-Tool#checking-the-result) gives every code. This tool keeps its own codes, which the two editions share and which are not those of the other tools here.
diff --git a/docs/Reference/COM-Interfaces/IConnectionPoint.md b/docs/Reference/COM-Interfaces/IConnectionPoint.md
index 0d0ba17bf..cfbfbcaf3 100644
--- a/docs/Reference/COM-Interfaces/IConnectionPoint.md
+++ b/docs/Reference/COM-Interfaces/IConnectionPoint.md
@@ -125,12 +125,12 @@ Returns a **Long**, the cookie that identifies the connection. The cookie is not
In twinBASIC:
- Cookies start at 1 for each source object and increase by one for every connection made. A cookie is not used again after its connection ends.
-- A sink that does not answer for the outgoing interface fails with `E_NOINTERFACE` (`&H80004002`), where the COM contract names `CONNECT_E_CANNOTCONNECT`. An object of an ordinary twinBASIC class is such a sink, even when the class implements **IDispatch**, and so is a class declared **NotDispatchable** that implements it. A twinBASIC class cannot answer for the identifier, because the identifier of a class's event interface changes with every build and so cannot be declared. A sink that works is the object twinBASIC creates for a **WithEvents** variable, which **EnumConnections** returns (see the example).
+- A sink that does not answer for the outgoing interface fails with `E_NOINTERFACE` (`&H80004002`), where the COM contract names `CONNECT_E_CANNOTCONNECT`. VB6 returns `E_NOINTERFACE` too. An object of an ordinary twinBASIC class is such a sink, even when the class implements **IDispatch**, and so is a class declared **NotDispatchable** that implements it. A twinBASIC class cannot answer for the identifier, because the identifier of a class's event interface changes with every build and so cannot be declared. A sink that works is the object twinBASIC creates for a **WithEvents** variable, which **EnumConnections** returns (see the example).
- A sink that is already connected to the point is not connected a second time. **Advise** raises no error, returns 0, and adds nothing.
- Passing **Nothing** is a defect in BETA 995, described in the warning below.
> [!WARNING]
-> BETA 995 has a defect: **Advise** with **Nothing** as the sink ends the program with an access violation, and any unsaved data is lost. The COM contract returns `E_POINTER`. Test the sink for **Nothing** before calling **Advise**.
+> BETA 995 has a defect: **Advise** with **Nothing** as the sink ends the program with an access violation, and any unsaved data is lost. The COM contract returns `E_POINTER`. VB6 ends with an access violation as well. Test the sink for **Nothing** before calling **Advise**.
### Unadvise
{: .no_toc }
@@ -145,7 +145,7 @@ Syntax: *object*.**Unadvise** *dwCookie*
The connection point releases the reference it held to the sink, and the source stops calling it. An event that the source raises afterwards does not reach that sink.
> [!WARNING]
-> BETA 995 has a defect: **Unadvise** with a cookie that names no connection, 0 included, succeeds and does nothing, where the COM contract returns an error (`E_POINTER`). A caller that releases a connection twice, or with a wrong cookie, is told that it worked.
+> BETA 995 has a defect: **Unadvise** with a cookie that names no connection, 0 included, succeeds and does nothing, where the COM contract returns an error (`E_POINTER`). VB6 returns `CONNECT_E_NOCONNECTION` (`&H80040200`) for a cookie it never issued. A caller that releases a connection twice, or with a wrong cookie, is told that it worked.
A **WithEvents** variable whose connection was ended with **Unadvise** can still be set to **Nothing** without an error.
diff --git a/docs/Reference/COM-Interfaces/IDispatch.md b/docs/Reference/COM-Interfaces/IDispatch.md
index 8867d7f9a..d9d4c5b10 100644
--- a/docs/Reference/COM-Interfaces/IDispatch.md
+++ b/docs/Reference/COM-Interfaces/IDispatch.md
@@ -231,7 +231,10 @@ The four [**VbCallType**](../../tB/Modules/Constants/VbCallType) values are the
> In VBA an unknown member raises error 438. In twinBASIC it raises `&H80020006`, so an error handler that tests for 438 does not catch it. This holds for objects of any origin: a twinBASIC class, a **Collection**, a **Dictionary** and a **FileSystemObject** all behave alike.
> [!WARNING]
-> BETA 995 has a defect: a call that fails inside **Invoke** can be made twice, so its side effects happen twice and nothing says so. VB6 makes the call once. A failed property assignment is followed by a second **Invoke** with *wFlags* 3, and **CallByName** repeats a call that returned `DISP_E_MEMBERNOTFOUND` with a null *pVarResult*. A call with more arguments than a **Sub** takes runs the **Sub**, then fails with error 13, and a late-bound statement does this twice. Code that implements **Invoke** must not change state before it can still fail.
+> BETA 995 has a defect: a call that fails inside **Invoke** can be made twice, so its side effects happen twice and nothing says so. VB6 makes the call once. A failed property assignment is followed by a second **Invoke** with *wFlags* 3. A call with more arguments than a **Sub** takes runs the **Sub**, then fails with error 13, and a late-bound statement does this twice. Code that implements **Invoke** must not change state before it can still fail.
+
+> [!NOTE]
+> **CallByName** repeats a call that returned `DISP_E_MEMBERNOTFOUND`: a second **Invoke** with the same identifier and *wFlags* and a null *pVarResult*, for **VbMethod**, **VbGet** and **VbLet** alike. VB6 does the same. A late-bound statement makes the call once.
## Classes written in twinBASIC
diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss
index 8cf11d0aa..4e9efe04c 100644
--- a/docs/_sass/custom/custom.scss
+++ b/docs/_sass/custom/custom.scss
@@ -622,3 +622,27 @@ div.highlight:focus-visible,
.main-content :not(pre, figure) > code {
white-space: pre-wrap;
}
+
+// ── The help add-in's pane ────────────────────────────────────────────────
+//
+// A page opened with `?pane=1`, and the pages reached from it in the same
+// tab, have `data-pane` on the root (the early script in builder/template.mjs
+// sets it) and show the page alone: no sidebar, and no header with the search
+// box, the auxiliary links and the theme toggle. The add-in has a search of
+// its own and passes the IDE's theme. The breadcrumbs stay, and so does the
+// footer, which holds the attribution line.
+//
+// `:root[data-pane]` is (0,2,0), which puts every rule here above its dark
+// re-emission (see "The specificity trap" in Builder.md): the header is shown
+// by `html[data-theme="dark"] .side-bar + .main .main-header` at (0,4,1), and
+// hidden below at (0,5,0).
+:root[data-pane] {
+ .side-bar,
+ .side-bar + .main .main-header {
+ display: none;
+ }
+
+ .side-bar + .main {
+ margin-left: auto;
+ }
+}
diff --git a/docs/assets/js/theme-toggle.js b/docs/assets/js/theme-toggle.js
index 3fe78a61d..a1e82a71d 100644
--- a/docs/assets/js/theme-toggle.js
+++ b/docs/assets/js/theme-toggle.js
@@ -12,8 +12,13 @@
// query keeps governing and re-resolves on OS change -- no matchMedia
// listener needed here).
//
-// A matching inline no-flash snippet in applies the stored override
-// before first paint; both MUST agree on the 'theme' storage key.
+// - sessionStorage['theme'] = 'light' | 'dark' when a page of this tab was
+// opened with ?theme=light or ?theme=dark (the help add-in passes the
+// IDE's theme so). It wins over localStorage and is never written there;
+// a click on the button replaces it with the reader's own choice.
+//
+// A matching inline no-flash snippet in applies the override before
+// first paint; both MUST agree on the 'theme' storage key.
(function () {
var KEY = "theme";
var ORDER = ["system", "light", "dark"];
@@ -37,7 +42,7 @@
function currentChoice() {
try {
- var stored = localStorage.getItem(KEY);
+ var stored = sessionStorage.getItem(KEY) || localStorage.getItem(KEY);
if (stored === "light" || stored === "dark") return stored;
} catch (_e) {
// localStorage unavailable (private mode) -- fall through to system.
@@ -45,18 +50,19 @@
return "system";
}
- function apply(choice, announce) {
+ // `persist` stores the choice as the reader's own, in place of any ?theme=
+ // override; the first sync after load only mirrors what is stored.
+ function apply(choice, announce, persist) {
if (choice === "system") {
root.removeAttribute("data-theme");
- try {
- localStorage.removeItem(KEY);
- } catch (_e) {
- // ignore: the preference simply won't persist
- }
} else {
root.setAttribute("data-theme", choice);
+ }
+ if (persist) {
try {
- localStorage.setItem(KEY, choice);
+ sessionStorage.removeItem(KEY);
+ if (choice === "system") localStorage.removeItem(KEY);
+ else localStorage.setItem(KEY, choice);
} catch (_e) {
// ignore: the preference simply won't persist
}
@@ -68,11 +74,11 @@
}
// Sync the button to whatever the no-flash snippet already applied, then reveal.
- apply(currentChoice(), false);
+ apply(currentChoice(), false, false);
button.hidden = false;
button.addEventListener("click", function () {
var next = ORDER[(ORDER.indexOf(currentChoice()) + 1) % ORDER.length];
- apply(next, true);
+ apply(next, true, true);
});
})();
diff --git a/scripts/addin_test.mjs b/scripts/addin_test.mjs
index c601e8c0a..2ffc16fca 100644
--- a/scripts/addin_test.mjs
+++ b/scripts/addin_test.mjs
@@ -35,7 +35,9 @@ Runs the IDE add-in scenarios: every lane in test/addin/lanes.mjs, each in a
process of its own with its own IDE copy, DevTools port and work folder.
--only only the lanes whose name matches
- --port base DevTools port (default 9560); the lanes get n, n+1, ...
+ --port base DevTools port (default 9560); the lanes get the
+ first free ports from n, passing over one another run
+ has claimed or something listens on
--jobs lanes at once (default 2)
--timeout a lane still running after this long is ended (default 600)
--ide the twinBASIC.exe to copy (default: $TB_IDE, else the
diff --git a/scripts/bug_repro.mjs b/scripts/bug_repro.mjs
index cbaa18190..692ce21cc 100644
--- a/scripts/bug_repro.mjs
+++ b/scripts/bug_repro.mjs
@@ -60,8 +60,9 @@
// -compare.png (lib/repro-images.mjs, lib/png.mjs) shows both and their
// difference. `verify` can judge by them (expect.imagesDiffer), and stages its
// pictures in the temp folder, so it never changes the tree.
-// 2. impexp EXITS 6 WHEN IT WARNS. `import` that finished with a warning is
-// still a pack, so 0 and 6 are both success, and its output is printed.
+// 2. THE PACK IS impexp RUN IN PROCESS (packTree, scripts/lib/tb-project.mjs).
+// It throws where the command line would have refused, and returns the
+// counts the command line prints, which `pack` prints as it does.
// 3. AN IDE THAT RUNS MANY REPRODUCERS OWNS THE REGISTRY TIDY ONCE. Under
// `verify` this process calls startTidy for every lane, so the tbbuild and
// tbrun children (which find TB_REGISTRY_OWNER set) leave the registry alone,
@@ -76,7 +77,7 @@
// stdin from the null device and a time limit, on the packed project, and
// compares its exit code and output. Paths are given backslashed and in full:
// `export` prefixes \\?\ to its project path, and cannot find a folder named
-// with forward slashes (WIP.md, "Driving the twinBASIC compiler").
+// with forward slashes (WIP.Harness.md, "Getting at the `.twin` sources").
// 6. EACH `cli` COMMAND RUNS ON A PRIVATE DESKTOP, in a kill-on-close job, as
// the IDE does (scripts/lib/tb-launch.ps1), with its output in files. The
// compiler opens a modal message box for a damaged project and waits on it
@@ -135,6 +136,7 @@ import {
writeComparison,
} from "./lib/repro-images.mjs";
import { buildNumber, compilerExe, findIde } from "./lib/tb-install.mjs";
+import { packTree } from "./lib/tb-project.mjs";
import { finishTidy, startTidy } from "./lib/tb-registry.mjs";
import {
NO_VB6,
@@ -263,7 +265,7 @@ Exit codes:
6 run, vb6: the probe printed nothing (vb6: no out.txt, or an empty one)
7 run: the probe ended before it returned
8 run --exe, vb6: the exe exited with a code other than 0, or was still running
- after --timeout
+ after --timeout; vb6: or the event log records that the exe faulted
9 run, vb6: a picture that repro.json's "images" names was not written, or could
not be read`;
@@ -416,7 +418,6 @@ function reproTemplates() {
.filter((n) => existsSync(path.join(dir, n, "Settings")) && existsSync(path.join(dir, n, "Sources")))
.sort();
}
-const IMPEXP = path.join(REPO_ROOT, "scripts", "impexp.mjs");
const TBBUILD = path.join(REPO_ROOT, "scripts", "tbbuild.mjs");
const TBRUN = path.join(REPO_ROOT, "scripts", "tbrun.mjs");
@@ -559,25 +560,29 @@ function newReproducer(slug, entryTitle, template = null, withVb6 = false, withI
// ------------------------------------------------------------------------ pack
-// Packs src/ into the .twinproj with impexp, then zips it with the files
-// repro.json's `attach` names; with a vb6/ folder, also zips its sources into
-// -vb6.zip. Returns what impexp printed, which the caller prints or not.
+// Packs src/ into the .twinproj with impexp, in process, then zips it with the
+// files repro.json's `attach` names; with a vb6/ folder, also zips its sources
+// into -vb6.zip. Returns what impexp's command line prints for an import,
+// then what was zipped, which the caller prints or not.
function pack(slug) {
const p = where(slug);
if (!existsSync(path.join(p.src, "Settings"))) throw new Fail(`no ${rel(p.src)}/Settings: nothing to pack`);
const repro = existsSync(p.repro) ? loadRepro(slug) : {};
const attach = repro.attach ?? [];
- const r = spawnSync(process.execPath, [IMPEXP, "import", p.twinproj, p.src, "--overwrite"], {
- encoding: "utf8",
- cwd: REPO_ROOT,
- windowsHide: true,
- });
- const said = `${r.stdout ?? ""}${r.stderr ?? ""}`;
- if (r.error || (r.status !== 0 && r.status !== 6)) {
- throw new Fail(
- `impexp import ${r.error ? `failed (${r.error.message})` : `exited ${r.status}`}:\n${said.trimEnd()}`,
- );
+ let counts;
+ try {
+ counts = packTree(p.twinproj, p.src);
+ } catch (e) {
+ throw new Fail(e.message);
}
+ const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
+ const said = `${[
+ `importing into "${p.twinproj}" from "${p.src}"...`,
+ ...counts.skipped.map((s) => ` skipped ${s.rel} (${s.why})`),
+ ...counts.added.map((a) => ` added ${a} (an empty folder the IDE expects)`),
+ ` ${plural(counts.files, "file")}, ${plural(counts.folders, "folder")}, ${counts.size} bytes`,
+ "... DONE",
+ ].join("\n")}\n`;
const pictures = imageZipEntries(p.dir);
const files = [
fileEntry(`${slug}.twinproj`, p.twinproj),
@@ -1109,6 +1114,15 @@ async function buildAndRunVb6(slug, exe, names, imagesDir) {
console.error(`vb6: ${REPRO_PROJECT}.exe was still running after ${timeout ?? 30} s and was ended`);
return 8;
}
+ // A VB6 exe that faults exits with code 0; the event log's record is what says it faulted.
+ if (r.fault) {
+ const { code, module, offset } = r.fault;
+ console.error(
+ `vb6: ${REPRO_PROJECT}.exe ended with exception 0x${code} in ${module} at offset 0x${offset} ` +
+ `(the Application event log's record; its exit code was ${r.status})`,
+ );
+ return 8;
+ }
if (!r.lines.length) {
console.error(`vb6: ${REPRO_PROJECT}.exe wrote no ${REPRO_OUT}, or an empty one`);
return 6;
diff --git a/scripts/census_attributes.mjs b/scripts/census_attributes.mjs
index 080127edc..9840ed52a 100644
--- a/scripts/census_attributes.mjs
+++ b/scripts/census_attributes.mjs
@@ -119,8 +119,8 @@ declaration keyword and by enclosing construct.
Exit codes:
0 the report was produced
- 2 a refused command line, no install, an install with no compiler or no package
- project, or a crash (a package that fails to export is left out of the census)`;
+ 2 a refused command line, no install, an install with no package project, or a
+ crash (a package that fails to export is left out of the census)`;
if (values.help) printHelpAndExit(USAGE);
diff --git a/scripts/check_examples.mjs b/scripts/check_examples.mjs
index c157f4e30..38c7700da 100644
--- a/scripts/check_examples.mjs
+++ b/scripts/check_examples.mjs
@@ -74,16 +74,7 @@
// and the rest without it, a crash that names none bisects, O(log n) builds, and
// one that needs several samples at once is reported with all of them.
-import {
- cpSync,
- existsSync,
- mkdirSync,
- promises as fs,
- readdirSync,
- readFileSync,
- rmSync,
- writeFileSync,
-} from "node:fs";
+import { cpSync, mkdirSync, promises as fs, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -142,7 +133,8 @@ import {
} from "./lib/example-run.mjs";
import { compileProject } from "./lib/tb-build.mjs";
import { wantShow } from "./lib/tb-ide.mjs";
-import { buildNumber, compilerExe, findIde, runCompiler } from "./lib/tb-install.mjs";
+import { buildNumber, findIde } from "./lib/tb-install.mjs";
+import { packTree } from "./lib/tb-project.mjs";
import { finishTidy, startTidy } from "./lib/tb-registry.mjs";
import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs";
@@ -215,7 +207,7 @@ Exit codes:
that fails the build, or a \`${RUN_MARKER}\` sample raised an error, did not return or
printed something other than the page says; the report names each
2 the harness could not run: a refused command line, a failed self-test probe, no
- IDE or compiler, an unreadable --report file, a work folder it could not clear,
+ IDE, an unreadable --report file, a work folder it could not clear,
an --llvm run on a Community or Personal licence, or a crash`;
if (values.help) printHelpAndExit(USAGE);
@@ -580,21 +572,16 @@ function stageBatch(batch, work) {
}
const proj = path.join(work, `b${index}.twinproj`);
- // Pure Windows paths: the compiler prefixes \\?\, which does not accept
- // forward slashes, and a mixed path fails with "input twinproj file does not
- // exist" rather than with anything about separators.
- const pack = runCompiler(COMPILER, ["import", proj.split("/").join("\\"), dir.split("/").join("\\"), "--overwrite"]);
- // import's exit code does not say whether it worked -- 0 on the failures it
- // reports, 999 on a tree holding an embedded package, which a resource= fence
- // staged under Packages/ would make -- so runCompiler reads the output.
- if (!pack.done) throw new Error(`packing failed${pack.why}:\n${pack.tail}`);
+ // The batch is packed in process by impexp (tb-project's packTree), which
+ // also packs a tree holding an embedded package, as a resource= fence staged
+ // under Packages/ makes.
+ packTree(proj, dir);
return { proj, dir, map, runs };
}
// ------------------------------------------------------------------- building
const IDE = findIde(values.ide);
-const COMPILER = IDE ? compilerExe(IDE) : null;
// The registry tidy for the whole run (lib/tb-registry.mjs): taken in main()
// before the first lane starts, finished once the last one has ended -- and
@@ -997,10 +984,6 @@ async function main() {
);
process.exit(2);
}
- if (!existsSync(COMPILER)) {
- console.error(`no compiler beside the IDE at ${COMPILER}`);
- process.exit(2);
- }
if (!selected.length) {
reportFindings();
diff --git a/scripts/check_gate_lists.mjs b/scripts/check_gate_lists.mjs
index 2af018c94..4ede5e0c6 100644
--- a/scripts/check_gate_lists.mjs
+++ b/scripts/check_gate_lists.mjs
@@ -93,7 +93,11 @@ const NUMBER_WORDS = [
"zero", "one", "two", "three", "four", "five",
"six", "seven", "eight", "nine", "ten", "eleven", "twelve",
"thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty",
+ "twenty-one", "twenty-two", "twenty-three", "twenty-four", "twenty-five",
];
+// Longest first, so that "twenty-one" is not read as "twenty" where a word
+// boundary follows the number, as in "of the twenty-one".
+const NUMBER_ALTERNATION = [...NUMBER_WORDS].sort((a, b) => b.length - a.length).join("|");
/** The body of a `### ` section: up to the next heading of any level. */
function sectionBody(src, heading) {
@@ -162,7 +166,7 @@ function commandRuns(src, file) {
/** The count a section claims in prose, as a number, or null if it makes none. */
function statedCount(body) {
// `steps?` because a one-gate wrapper would correctly write "One step".
- const m = new RegExp(`\\b(${NUMBER_WORDS.join("|")})\\s+steps?\\b`, "i").exec(body);
+ const m = new RegExp(`\\b(${NUMBER_ALTERNATION})\\s+steps?\\b`, "i").exec(body);
return m ? NUMBER_WORDS.indexOf(m[1].toLowerCase()) : null;
}
@@ -185,7 +189,7 @@ function statedCount(body) {
// sweep is per section, and a section's subject wrapper is the one named in
// its heading or on the command line directly beneath it.
-const NUM = `(?:${NUMBER_WORDS.join("|")}|\\d+)`;
+const NUM = `(?:${NUMBER_ALTERNATION}|\\d+)`;
const WRAP = "`?(check|test)\\.bat`?";
// Deliberately not `checks?`: a count of checks is ordinary English in the
// corpus ("Two checks enforce the registration", "two more checks"), not a
diff --git a/scripts/check_tb_registry.mjs b/scripts/check_tb_registry.mjs
index 4082a0127..f7902dcca 100644
--- a/scripts/check_tb_registry.mjs
+++ b/scripts/check_tb_registry.mjs
@@ -37,6 +37,9 @@
// that is not JSON left alone; and a named project's target that the run
// switched, saved under another spelling of its path, put back in its
// place, with an entry for a project that had none deleted;
+// * the IDE's theme, colorTheme in its GENERAL options: put back, with
+// every other option left as it is now; removed when the run set one
+// where there was none; options that are not JSON left alone;
// * an association that named the temp folder when the run began, which is
// another run's IDE copy's and is left alone, against one that did not,
// which is put back;
@@ -292,6 +295,27 @@ try {
assert.equal(R.finishTidy(named).architecture, 1);
assert.equal(readValue(SETTINGS, MEMORY), JSON.stringify(kept), "finishTidy puts a named project's target back");
+ // ------------------------------------------------ the theme
+ // The IDE's options, written as the IDE writes them; a run switches the
+ // theme, and the user changes another option meanwhile, which is kept.
+ const asIde = (o) => JSON.stringify(o, undefined, "\t");
+ const options = { colorTheme: "Dark", fontSize: 14 };
+ setValues(SETTINGS, { GENERAL: asIde(options) });
+ const themed = R.startTidy({ root: ROOT, keys: [ASSOC] });
+ setValues(SETTINGS, { GENERAL: asIde({ colorTheme: "light", fontSize: 16 }) });
+ assert.equal(R.finishTidy(themed).theme, 1);
+ assert.equal(readValue(SETTINGS, "GENERAL"), asIde({ colorTheme: "Dark", fontSize: 16 }), "the theme comes back");
+ assert.equal(R.restoreTheme(themed.theme), 0, "a second restore writes nothing");
+ setValues(SETTINGS, { GENERAL: asIde({ fontSize: 14 }) });
+ const unset = R.snapshotTheme({ root: ROOT });
+ setValues(SETTINGS, { GENERAL: asIde({ fontSize: 14, colorTheme: "light" }) });
+ assert.equal(R.restoreTheme(unset), 1);
+ assert.equal(readValue(SETTINGS, "GENERAL"), asIde({ fontSize: 14 }), "a theme the run set where none was goes");
+ setValues(SETTINGS, { GENERAL: "{not json" });
+ assert.equal(R.restoreTheme(themed.theme), 0);
+ assert.equal(readValue(SETTINGS, "GENERAL"), "{not json", "options that are not JSON are left alone");
+ deleteValues(SETTINGS, ["GENERAL"]);
+
// ------------------------------------------------ an association another run's copy held
// startTidy and finishTidy, the whole tidy, on the scratch keys.
const COMMAND = ASSOC + "\\shell\\open\\command";
diff --git a/scripts/check_twin_parsers.mjs b/scripts/check_twin_parsers.mjs
index 1e8055816..86221531b 100644
--- a/scripts/check_twin_parsers.mjs
+++ b/scripts/check_twin_parsers.mjs
@@ -23,7 +23,8 @@
// `Applicable to:` line into gen_attribute_probes.mjs's targets.
// - wrapProbe (scripts/lib/tb-probe.mjs), which moves a tbrun probe's
// [RunAfterBuild] to a wrapper; a Sub it wraps wrongly runs the wrong code,
-// and one it misses loses tbrun's check that the probe returned.
+// one it misses loses tbrun's check that the probe returned, and a wrapper
+// that does not set TbRun's flag sends TbRun.Out's lines past the console.
// - sentinelIndex (the same module), which reads that check from the console;
// a line the IDE prints after a return must not read as the probe ending.
@@ -31,7 +32,7 @@ import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/
import { parseTargets } from "./lib/attributes-doc.mjs";
import { createProbes } from "./lib/gate-probes.mjs";
import { classify } from "./lib/tb-fences.mjs";
-import { SENTINEL, sentinelIndex, TBRUN_FILE, WRAPPER_SUB, wrapProbe } from "./lib/tb-probe.mjs";
+import { IDE_FLAG, SENTINEL, sentinelIndex, TBRUN_FILE, WRAPPER_SUB, wrapProbe } from "./lib/tb-probe.mjs";
import { parseTwin } from "./lib/twin-api.mjs";
import { MODIFIERS, declarationKind } from "./lib/twin-declarations.mjs";
@@ -190,9 +191,9 @@ for (const [what, text, want] of [
const [before, after] = [text.split(eol), out.split(eol)];
const attrs = after.filter((l) => /\[RunAfterBuild\]/.test(l)).length;
const call = after.findIndex((l) => l.trim() === `Public Sub ${WRAPPER_SUB}()`);
- // The wrapper is six lines, from its attribute to a blank line; without
+ // The wrapper is seven lines, from its attribute to a blank line; without
// them, the file is the probe's with its attribute blanked.
- const rest = after.filter((_, i) => i < call - 1 || i >= call + 5);
+ const rest = after.filter((_, i) => i < call - 1 || i >= call + 6);
const kept =
rest.length === before.length &&
before.every((l, i) => rest[i] === l || rest[i] === l.replace("[RunAfterBuild]", " ".repeat(15)));
@@ -200,8 +201,9 @@ for (const [what, text, want] of [
ok =
kept &&
attrs === 1 &&
- after[call + 1].trim() === want &&
- after[call + 2].trim() === `Debug.Print "${SENTINEL}"` &&
+ after[call + 1].trim() === `TbRun.${IDE_FLAG} = True` &&
+ after[call + 2].trim() === want &&
+ after[call + 3].trim() === `Debug.Print "${SENTINEL}"` &&
endModule > call &&
!after.slice(0, call).some((l) => l.includes(WRAPPER_SUB));
}
@@ -212,6 +214,20 @@ check(
!wrapProbe([{ name: "Mine.twin", text: "Module TbRun\nEnd Module\n" }]).files.some((f) => f.name === TBRUN_FILE),
"",
);
+{
+ // Nor a wrapper that sets a flag the tree's own module may not have.
+ const r = wrapProbe([
+ { name: "Mine.twin", text: "Module TbRun\nEnd Module\n" },
+ { name: "Probe.twin", text: probe(" [RunAfterBuild]\n Sub Go()\n End Sub") },
+ ]);
+ const after = r.files.find((f) => f.name === "Probe.twin")?.text.split("\n") ?? [];
+ const call = after.findIndex((l) => l.trim() === `Public Sub ${WRAPPER_SUB}()`);
+ check(
+ "wrapProbe: a tree with its own Module TbRun gets a wrapper that sets no flag",
+ r.wrapped?.sub === "Go" && call >= 0 && after[call + 1].trim() === "Go" && !after.some((l) => l.includes(IDE_FLAG)),
+ show({ wrapped: r.wrapped, why: r.why, out: after.join("\n") }),
+ );
+}
// ---------------------------------------------------------------- sentinelIndex
// What tbrun reads to tell a probe that returned from one that ended first.
diff --git a/scripts/gen_attribute_probes.mjs b/scripts/gen_attribute_probes.mjs
index 9af8420fd..bd3dcadc8 100644
--- a/scripts/gen_attribute_probes.mjs
+++ b/scripts/gen_attribute_probes.mjs
@@ -30,7 +30,7 @@
//
// Then pack each tree and open the result in the IDE:
//
-// bin\twinBASIC_win32.exe import AttributeProbes.twinproj --overwrite
+// node scripts/impexp.mjs import AttributeProbes.twinproj --overwrite
//
// Re-run after editing `Attributes.md`; the key cites the line each probe came
// from.
diff --git a/scripts/ide_test.mjs b/scripts/ide_test.mjs
index 70b45637f..63c9590bd 100644
--- a/scripts/ide_test.mjs
+++ b/scripts/ide_test.mjs
@@ -23,7 +23,9 @@ Runs the IDE scenarios: every lane in test/ide/lanes.mjs, each in a process of
its own with its own IDE copy, DevTools port and work folder.
--only only the lanes whose name matches
- --port base DevTools port (default 9660); the lanes get n, n+1, ...
+ --port base DevTools port (default 9660); the lanes get the
+ first free ports from n, passing over one another run
+ has claimed or something listens on
--jobs lanes at once (default 2)
--timeout a lane still running after this long is ended (default 600)
--ide the twinBASIC.exe to copy (default: $TB_IDE, else the
diff --git a/scripts/impexp.mjs b/scripts/impexp.mjs
index b0f05ad21..40675b2f5 100644
--- a/scripts/impexp.mjs
+++ b/scripts/impexp.mjs
@@ -21,6 +21,12 @@
//
// The exit code says why a command failed, and whether it warned; the usage
// message lists the codes.
+//
+// It can also be imported as a module. exportProject and importProject do what
+// the two verbs do, with the same arguments and { overwrite }. Each returns
+// what the command line prints -- the counts, the skipped and added entries,
+// the warnings -- and throws where it reports an error: a Refusal, whose
+// reason names its exit code, has written nothing.
import fs from "node:fs";
import path from "node:path";
@@ -259,7 +265,7 @@ function serialize(root) {
// A refusal the command line reports as `ERROR:` lines, with nothing written.
// Its reason is one of the EXIT keys below.
-class Refusal extends Error {
+export class Refusal extends Error {
constructor(reason, message, details = []) {
super(message);
this.reason = reason;
@@ -351,7 +357,7 @@ function isSafeName(name) {
return name !== "" && !/[\\/:*?"<>|\x00-\x1f]/.test(name) && !/[. ]$/.test(name);
}
-function exportProject(projectPath, folder, { overwrite = false } = {}) {
+export function exportProject(projectPath, folder, { overwrite = false } = {}) {
const root = readProject(projectPath);
// Everything is checked before anything is written, so a refusal leaves the
@@ -521,7 +527,7 @@ function buildTree(dirPath, rel, self, skipped, added) {
};
}
-function importProject(projectPath, folder, { overwrite = false } = {}) {
+export function importProject(projectPath, folder, { overwrite = false } = {}) {
// Everything is checked before the tree is read, so a refusal leaves the
// project file exactly as it was.
if (kindOf(folder) !== "folder") throw new Refusal("missing", `input folder does not exist: ${folder}`, HINT_IMPORT);
@@ -1079,4 +1085,16 @@ function main(argv) {
}
}
-process.exitCode = main(process.argv.slice(2));
+// The command line runs only when this file is run, not when it is imported.
+// Node loads the file it runs from its real path, so the comparison is between
+// real paths: process.argv[1] alone would not match when the file is run
+// through a symbolic link, and the command would silently do nothing.
+function isRun() {
+ try {
+ return fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
+ } catch {
+ return false;
+ }
+}
+
+if (isRun()) process.exitCode = main(process.argv.slice(2));
diff --git a/scripts/lib/cli-cases.mjs b/scripts/lib/cli-cases.mjs
index 45bc9dd88..a35214864 100644
--- a/scripts/lib/cli-cases.mjs
+++ b/scripts/lib/cli-cases.mjs
@@ -120,6 +120,10 @@ const CASES = [
{ tool: "scripts/ide_test.mjs", args: ["--ide"], exit: 2, stderr: "--ide needs a value\n" },
{ tool: "scripts/ide_test.mjs", args: ["--ide", ""], exit: 2, stderr: "--ide needs a non-empty value\n" },
{ tool: "scripts/ide_test.mjs", args: ["--ide", "no-such.exe"], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / },
+ { tool: "scripts/try_help_addin.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/try_help_addin\.mjs / },
+ { tool: "scripts/try_help_addin.mjs", args: ["--ide"], exit: 2, stderr: "--ide needs a value\n" },
+ { tool: "scripts/try_help_addin.mjs", args: ["--ide", ""], exit: 2, stderr: "--ide needs a non-empty value\n" },
+ { tool: "scripts/try_help_addin.mjs", args: ["--ide", "no-such.exe"], exit: 2, stderr: /^no twinBASIC IDE found: pass --ide / },
{ tool: "scripts/check_examples.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/check_examples\.mjs \[options\]\n/ },
{ tool: "scripts/check_examples.mjs", args: ["--jobs", "0"], exit: 2, stderr: "check_examples: --jobs expects a whole number of at least 1, got: 0\n" },
{ tool: "scripts/check_examples.mjs", args: ["--batch", "1.5"], exit: 2, stderr: "check_examples: --batch expects a whole number of at least 1, got: 1.5\n" },
@@ -332,6 +336,7 @@ const HELP_TOOLS = {
"eval/transcript.mjs": "Usage: node eval/transcript.mjs ",
"scripts/addin_test.mjs": null,
"scripts/ide_test.mjs": null,
+ "scripts/try_help_addin.mjs": null,
"scripts/bug_repro.mjs": null,
"scripts/build_dot_metrics.mjs": null,
"scripts/build_package_api.mjs": null,
@@ -405,6 +410,7 @@ const REFUSALS = {
"eval/transcript.mjs": [null],
"scripts/addin_test.mjs": ["ide"],
"scripts/ide_test.mjs": ["ide"],
+ "scripts/try_help_addin.mjs": ["ide"],
"scripts/bug_repro.mjs": ["ide"],
"scripts/build_dot_metrics.mjs": [null],
"scripts/build_package_api.mjs": ["out"],
@@ -708,6 +714,9 @@ bad(
);
bad("scripts/ide_test.mjs", ["--show", "--hide"], "--show and --hide cannot be given together\n");
+bad("scripts/try_help_addin.mjs", ["--port", "0"], NOT_PORT(0) + "\n");
+bad("scripts/try_help_addin.mjs", ["--port=1.5"], NOT_PORT(1.5) + "\n");
+
// check_examples prints "check_examples: " before the message.
{
const tool = "scripts/check_examples.mjs";
diff --git a/scripts/lib/example-batches.mjs b/scripts/lib/example-batches.mjs
index fcd2bd7bc..4c4588819 100644
--- a/scripts/lib/example-batches.mjs
+++ b/scripts/lib/example-batches.mjs
@@ -55,9 +55,9 @@ export const DEFAULT_BATCH = 120;
*
* The relation lives in the tool rather than in the tree on purpose. The
* alternative was a marker file in the template directory, and a template
- * directory is an exported twinBASIC project that the compiler's `import` verb
- * has to accept -- so a stray file there is a thing to test rather than a thing
- * to declare.
+ * directory is an exported twinBASIC project, which impexp packs whole -- every
+ * file in it but a .git folder goes into the batch's project -- so a stray file
+ * there is a thing to test rather than a thing to declare.
*/
export const TEMPLATE_BASE = {
"vb-private": "console",
diff --git a/scripts/lib/lane-runner.mjs b/scripts/lib/lane-runner.mjs
index 8bc060ba6..47b6ec793 100644
--- a/scripts/lib/lane-runner.mjs
+++ b/scripts/lib/lane-runner.mjs
@@ -12,7 +12,8 @@
// Options, as each tool's usage text states them:
//
// --only only the lanes whose name matches
-// --port base DevTools port; the lanes get n, n+1, ...
+// --port base DevTools port; the lanes get the first free ones
+// from n (lib/tb-ports.mjs)
// --jobs lanes at once (default 2)
// --timeout a lane still running after this long is ended (default 600)
// --ide the twinBASIC.exe to copy (default: $TB_IDE, else the
@@ -76,6 +77,7 @@ import { removeTree } from "./tb-ide-copy.mjs";
import { wantShow } from "./tb-ide.mjs";
import { buildNumber, findIde } from "./tb-install.mjs";
import { LANE_ENV } from "./tb-lane.mjs";
+import { claimPorts } from "./tb-ports.mjs";
import {
alive,
deleteSettings,
@@ -103,6 +105,17 @@ import {
* @param {string} o.usage the tool's whole usage text, printed by --help and on a refused command line
* @param {string} o.subject what a lane's scenario operates, as a noun in messages ("add-in")
*/
+// Ports as a short list: runs of consecutive ones as a range, "9560-9563, 9565".
+function portList(ports) {
+ const runs = [];
+ for (const p of ports) {
+ const last = runs.at(-1);
+ if (last && p === last[1] + 1) last[1] = p;
+ else runs.push([p, p]);
+ }
+ return runs.map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(", ");
+}
+
export async function runLanes({ tool, suite: SUITE, workDir, defaultPort, usage: USAGE, subject }) {
const { values } = withUsageError(() =>
parseCli(process.argv.slice(2), {
@@ -152,12 +165,7 @@ export async function runLanes({ tool, suite: SUITE, workDir, defaultPort, usage
const lanes = manifest
.map((l) => ({ ...l, name: l.name ?? path.basename(l.file).replace(/\.test\.mjs$/, "") }))
.filter((l) => !only || only.test(l.name))
- .map((l, i) => ({
- ...l,
- settings: l.settings ?? [],
- port: basePort + i,
- work: path.join(tmpdir(), workDir, String(basePort + i)),
- }));
+ .map((l) => ({ ...l, settings: l.settings ?? [] }));
if (!lanes.length) die(2, `no lane in ${path.join(SUITE, "lanes.mjs")} matches ${only}`);
for (const l of lanes) {
if (!existsSync(path.join(SUITE, l.file))) die(2, `lane ${l.name}: no file ${path.join(SUITE, l.file)}`);
@@ -168,6 +176,19 @@ export async function runLanes({ tool, suite: SUITE, workDir, defaultPort, usage
}
}
+ // Each lane gets a port of its own, the first free ones from the base: a port
+ // another run has claimed, or that something listens on, is passed over.
+ let ports;
+ try {
+ ports = await claimPorts(lanes.length, { from: basePort });
+ } catch (e) {
+ die(2, e.message);
+ }
+ lanes.forEach((l, i) => {
+ l.port = ports[i];
+ l.work = path.join(tmpdir(), workDir, String(ports[i]));
+ });
+
// ---------------------------------------------------------------- the registry
// startTidy leaves the registry to a live owner that is not this process, and
@@ -234,8 +255,8 @@ export async function runLanes({ tool, suite: SUITE, workDir, defaultPort, usage
// ---------------------------------------------------------------- the lanes
console.log(
- `${tool}: BETA ${buildNumber(ide) ?? "?"}, ${lanes.length} lane(s) on ports ` +
- `${basePort}-${basePort + lanes.length - 1}, ${Math.min(jobs, lanes.length)} at a time`,
+ `${tool}: BETA ${buildNumber(ide) ?? "?"}, ${lanes.length} lane(s) on ports ${portList(ports)}, ` +
+ `${Math.min(jobs, lanes.length)} at a time`,
);
process.on("SIGINT", () => {
diff --git a/scripts/lib/tb-addin.mjs b/scripts/lib/tb-addin.mjs
index 5a93594d7..8ea2c67f1 100644
--- a/scripts/lib/tb-addin.mjs
+++ b/scripts/lib/tb-addin.mjs
@@ -26,7 +26,6 @@
import { mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
-import { compilerExe } from "./tb-install.mjs";
import {
attachIde,
buildProject,
@@ -148,7 +147,6 @@ export async function buildAddin({
src,
stage: path.join(work, "addin-src"),
project,
- compiler: compilerExe(ide),
settings: (original) => {
dll = path.join(work, "out", `${original["project.name"]}.dll`);
return { "project.buildPath": dll, "project.id": laneProjectId(1, port) };
diff --git a/scripts/lib/tb-cdp.mjs b/scripts/lib/tb-cdp.mjs
index cf16e4c72..0bd2706c3 100644
--- a/scripts/lib/tb-cdp.mjs
+++ b/scripts/lib/tb-cdp.mjs
@@ -73,6 +73,12 @@ export async function attach(port, match = "main.htm", { timeout = 30 * 1000 } =
const send = (method, params = {}, { timeout: ms = timeout } = {}) =>
new Promise((res, rej) => {
+ // A WebSocket that is closing or closed discards what it is sent and does
+ // not throw, so the call would wait out its whole timeout.
+ if (ws.readyState !== WebSocket.OPEN) {
+ rej(new Error("the DevTools connection is closed"));
+ return;
+ }
const i = ++id;
const timer = setTimeout(() => {
pending.delete(i);
diff --git a/scripts/lib/tb-ide.mjs b/scripts/lib/tb-ide.mjs
index 877ba9e0d..ffa6522a7 100644
--- a/scripts/lib/tb-ide.mjs
+++ b/scripts/lib/tb-ide.mjs
@@ -482,7 +482,7 @@ export async function attachIde(port, { tries = 60 } = {}) {
// binds 127.0.0.1, so binding it ourselves for a moment is the test. Resolves
// to the bind's error code, or null when the port is free: a port Windows has
// reserved refuses the bind with EACCES though nothing listens on it.
-const portTaken = (port) =>
+export const portTaken = (port) =>
new Promise((resolve) => {
const s = net.createServer();
s.once("error", (e) => resolve(e.code ?? "an error"));
@@ -975,8 +975,15 @@ export async function awaitNewCompiler(c, before, { why = "restarting it", timeo
// "[LINKER] SUCCESS created output file ''" or with one of some twenty
// failure lines: "[LINKER] FAILED ...", "[BUILD] FAILED ...", "[BUILD] ERROR
// ...", "[BUILD] failed" and "[LINKER] compilation (codegen) error ...".
+// A package writes "[BUILD] Creating TWINPACK file ''" and then
+// "[BUILD] successful." (BETA 995); it fails with "[BUILD] FAILED to create
+// output file", "[BUILD] FAILED to write to output file" or "[BUILD] failed".
+// The success line is matched whole: "[BUILD] successfully built Fusion server
+// file" is another line.
const BUILD_START = "[BUILD] Starting...";
const BUILD_OK = /^\[LINKER\] SUCCESS created output file '(.+)'$/;
+const PACKAGE_FILE = /^\[BUILD\] Creating TWINPACK file '(.+)'$/;
+const PACKAGE_OK = "[BUILD] successful.";
// The last is LLVM's refusal of a procedure, which has no bracketed prefix:
// "LLVM compilation error in 'Probe.Stopper': Unable to compile due to use of
// datatype that is not yet supported for LLVM compilation" (BETA 983).
@@ -1044,16 +1051,16 @@ async function buildLines(c, mark, clearsBefore) {
* Build the open project, as the toolbar's Build button does, and wait for the
* build log to say how it went.
*
- * Only a binary is recognised, an EXE or a DLL, whose log ends with the
- * linker's SUCCESS line. What a package build writes has not been looked at,
- * and one would end in the timeout.
+ * A binary, an EXE or a DLL, has succeeded when its log shows the linker's
+ * SUCCESS line, and a package when its log names the TWINPACK file and then
+ * says the build was successful.
*
* @param {object} c a tb-cdp connection
* @param {object} [o]
* @param {number} [o.timeout] milliseconds (default 120000)
* @returns {Promise<{ok: boolean, file?: string, message?: string, log: string[]}>}
- * `file` is the path the linker says it created; `log` is the console from
- * the build's first line on
+ * `file` is the path the log says the build created; `log` is the console
+ * from the build's first line on
*/
export async function buildProject(c, { timeout = 120 * 1000 } = {}) {
const mark = await consoleMark(c);
@@ -1085,6 +1092,8 @@ export async function buildProject(c, { timeout = 120 * 1000 } = {}) {
log = lines.slice(start);
const ok = log.map((l) => BUILD_OK.exec(l)).find(Boolean);
if (ok) return { ok: true, file: ok[1], log };
+ const pkg = log.findIndex((l) => PACKAGE_FILE.test(l));
+ if (pkg >= 0 && log.includes(PACKAGE_OK, pkg)) return { ok: true, file: PACKAGE_FILE.exec(log[pkg])[1], log };
// Not every such line need end the build -- the strings include "[BUILD]
// failed to use project.iconForm setting", and whether a build goes on
// after that one has not been seen -- so one decides only after two
diff --git a/scripts/lib/tb-install.mjs b/scripts/lib/tb-install.mjs
index a5618230c..466b91e9a 100644
--- a/scripts/lib/tb-install.mjs
+++ b/scripts/lib/tb-install.mjs
@@ -1,13 +1,12 @@
-// Finding the twinBASIC install, and running its compiler executable. Shared,
-// because three tools now need it and a third private copy is how two of them
-// would come to disagree.
+// Finding the twinBASIC install, and the compiler executable beside its IDE.
+// Shared, because several tools need it and a private copy in each is how two
+// of them would come to disagree.
//
// NO INSTALL PATH IS HARDCODED, here or anywhere in this tooling: an install
// path contains a username. Pass one explicitly, set TB_IDE, or unpack the IDE
// where its own zip says to, which is a twinBASIC_IDE_BETA_ folder on the
// Desktop.
-import { spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -44,39 +43,15 @@ export function findIde(explicit) {
/**
* The compiler executable beside an IDE. It is the one with a command line --
- * `export`, `import` and four more verbs, none of which builds -- so packing a
- * source tree into a .twinproj goes through this and compiling does not.
+ * `export`, `import` and four more verbs, none of which builds. Nothing here
+ * packs or unpacks through it: impexp does, in process (tb-project.mjs). It is
+ * for a reproducer's own command line (bug_repro's `cli` mode) and for a test
+ * of what the compiler's own verbs do.
*/
export function compilerExe(ide) {
return path.join(path.dirname(ide), "bin", "twinBASIC_win32.exe");
}
-/**
- * Run one of the compiler executable's verbs and say whether it finished.
- *
- * The exit code cannot say so. `import` and `export` exit 0 on every failure
- * they report themselves, and `import` exits 999 with no result line at all
- * when the tree has a folder inside its top-level `Packages` -- any project
- * that embeds a package (twinbasic/twinbasic#841). A last line of `... DONE` is the
- * only success, so that is the test, whatever the exit status. Not
- * execFileSync: it throws on a non-zero exit, so a 999 escaped as an exception
- * before the output was ever read, and tbrun reported it as compile errors.
- *
- * @returns {{done: boolean, why: string, tail: string}} `why` is empty, or the
- * exit code or spawn error in parentheses, ready to follow a word such as
- * "packing failed"; `tail` is the last three lines of output
- */
-export function runCompiler(exe, args) {
- const r = spawnSync(exe, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
- const out = (r.stdout ?? "").trim();
- const lines = [out, (r.stderr ?? "").trim()].filter(Boolean).join("\n").split(/\r?\n/);
- return {
- done: !r.error && /\.\.\. DONE$/.test(out),
- why: r.error ? ` (${r.error.message})` : r.status ? ` (exit code ${r.status})` : "",
- tail: lines.slice(-3).join("\n"),
- };
-}
-
/** The build number of an install, for reporting. Null if it cannot be read. */
export function buildNumber(ide) {
const m = /twinBASIC_IDE_BETA_(\d+)/.exec(ide ?? "");
diff --git a/scripts/lib/tb-lane.mjs b/scripts/lib/tb-lane.mjs
index 71252759f..1f701721d 100644
--- a/scripts/lib/tb-lane.mjs
+++ b/scripts/lib/tb-lane.mjs
@@ -38,9 +38,8 @@ import {
waitForCompile,
} from "./tb-ide.mjs";
import { checkAddinsRoot } from "./tb-ide-addins.mjs";
-import { compilerExe, runCompiler } from "./tb-install.mjs";
import { restartCompiler } from "./tb-operate.mjs";
-import { laneProjectId, stageProject } from "./tb-project.mjs";
+import { laneProjectId, stageProject, unpackProject } from "./tb-project.mjs";
/** The environment variable the runner hands a lane to its scenario file in. */
export const LANE_ENV = "TB_ADDIN_LANE";
@@ -51,8 +50,6 @@ export function addinLane() {
return raw ? new Lane(JSON.parse(raw)) : null;
}
-const winPath = (p) => path.resolve(p).split("/").join("\\");
-
export class Lane {
/**
* @param {object} o
@@ -105,17 +102,14 @@ export class Lane {
const files = readdirSync(dir).filter((f) => /\.twinproj$/i.test(f));
if (files.length !== 1) throw new Error(`${dir} holds ${files.length} .twinproj files, not one`);
const out = path.join(this.work, "samples", sample.replace(/[^A-Za-z0-9]+/g, "-"));
+ // The folder is the lane's own, and removed first, since unpackProject
+ // refuses a folder that holds files the project does not.
rmSync(out, { recursive: true, force: true });
- mkdirSync(out, { recursive: true });
- // export wants backslashes, a full path to the project and a trailing
- // separator on the folder (WIP.md, Driving the twinBASIC compiler).
- const r = runCompiler(compilerExe(this.copy()), [
- "export",
- winPath(path.join(dir, files[0])),
- `${winPath(out)}\\`,
- "--overwrite",
- ]);
- if (!r.done) throw new Error(`exporting ${sample} failed${r.why}:\n${r.tail}`);
+ try {
+ unpackProject(path.join(dir, files[0]), out);
+ } catch (e) {
+ throw new Error(`exporting ${sample} failed:\n${e.message}`);
+ }
return out;
}
@@ -128,10 +122,12 @@ export class Lane {
* @param {string} src
* @param {object} [o]
* @param {string} [o.arch] the build target, "win32" (the default) or "win64"
+ * @param {boolean} [o.show] build on the user's desktop; by default where the
+ * lane opens its IDEs
* @returns {Promise<{dll: string, arch: string, diagnostics: string[], log: string[]}>}
* buildAddin's result; it throws, with an exitCode, when the add-in does not build
*/
- async buildAddin(src, { arch = "win32" } = {}) {
+ async buildAddin(src, { arch = "win32", show = this.show } = {}) {
if (this.run) throw new Error(`lane ${this.name}: close the open project before building an add-in`);
return buildAddin({
ide: this.copy(),
@@ -139,7 +135,7 @@ export class Lane {
work: path.join(this.work, `addin${++this.builds}`),
port: this.port,
arch,
- show: this.show,
+ show,
appdata: this.appdataDir(),
});
}
@@ -152,11 +148,12 @@ export class Lane {
* @param {string} src
* @param {object} [o]
* @param {string} [o.arch] as for buildAddin
+ * @param {boolean} [o.show] as for buildAddin
* @returns {Promise<{dll: string, arch: string, diagnostics: string[], log: string[]}>}
* as buildAddin
*/
- async addAddin(src, { arch = "win32" } = {}) {
- const built = await this.buildAddin(src, { arch });
+ async addAddin(src, { arch = "win32", show = this.show } = {}) {
+ const built = await this.buildAddin(src, { arch, show });
this.placeAddin(built.dll, { arch });
return built;
}
@@ -211,7 +208,6 @@ export class Lane {
src,
stage: path.join(this.work, "project-src"),
project,
- compiler: compilerExe(exe),
settings: (original) => ({
"project.buildPath": path.join(this.work, "out", `${original["project.name"]}.exe`),
"project.id": laneProjectId(2, this.port),
diff --git a/scripts/lib/tb-packages.mjs b/scripts/lib/tb-packages.mjs
index 0f125542c..9d41c7c76 100644
--- a/scripts/lib/tb-packages.mjs
+++ b/scripts/lib/tb-packages.mjs
@@ -5,19 +5,13 @@
//
// A package's sources are inside a .twinproj archive under the install's
// `packages\`, one folder per package, named `{GUID}_`, with a leading
-// dot on the two the IDE hides: `.{...}_VBA` and `.{...}_VBRUN`. The compiler
-// executable's `export` verb unpacks one into `Settings` plus `Sources\`.
-//
-// Two traps, both from WIP.md and both still live: every path given the
-// executable must use backslashes (path.join gives them here), because a folder
-// named with forward slashes cannot be created or even found; and stdin has to
-// be detached, or the executable consumes the caller's and a loop's later
-// iterations never run.
+// dot on the two the IDE hides: `.{...}_VBA` and `.{...}_VBRUN`. impexp, run in
+// process (tb-project.mjs), unpacks one into `Settings` plus `Sources\`.
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
-import { runCompiler } from "./tb-install.mjs";
+import { unpackProject } from "./tb-project.mjs";
/** The package's name, from its folder: `.{54F9...}_VBA` gives `VBA`. */
export const packageName = (folder) => folder.replace(/^\.?\{[^}]+\}_/, "");
@@ -30,9 +24,11 @@ export const defaultCache = (build) => path.join(os.tmpdir(), "tb-census", `beta
* under `projects\` and `addins\` too -- into `cache\\`. A
* folder already in the cache is taken as a finished export unless `refresh`
* is set: a failed export removes its folder, so one left behind is complete.
+ * A refresh clears the folder first, as unpackProject refuses one that holds
+ * files the project does not.
*
* @param {object} o
- * @param {string} o.root the install root, holding `bin\` and `packages\`
+ * @param {string} o.root the install root, holding `packages\`
* @param {string} o.cache
* @param {boolean} [o.refresh]
* @param {boolean} [o.samples]
@@ -42,9 +38,6 @@ export const defaultCache = (build) => path.join(os.tmpdir(), "tb-census", `beta
* folder its export is in; a failed one is in `failed` as well
*/
export function exportPackages({ root, cache, refresh = false, samples = false, log = () => {} }) {
- const exe = path.join(root, "bin", "twinBASIC_win32.exe");
- if (!existsSync(exe)) throw new Error(`no compiler at ${exe}`);
-
const roots = [path.join(root, "packages")];
if (samples) {
for (const d of ["projects", "addins"]) {
@@ -77,14 +70,15 @@ export function exportPackages({ root, cache, refresh = false, samples = false,
const failed = [];
for (const p of projects) {
if (existsSync(p.dir) && !refresh) continue;
- mkdirSync(p.dir, { recursive: true });
- // The exit code does not say whether export worked -- see runCompiler.
- const r = runCompiler(exe, ["export", p.proj, p.dir + path.sep, "--overwrite"]);
- if (r.done) {
+ // The cache's own folder, cleared rather than exported over: unpackProject
+ // refuses a folder that holds files the project does not.
+ rmSync(p.dir, { recursive: true, force: true });
+ try {
+ unpackProject(p.proj, p.dir);
exported++;
- } else {
+ } catch (e) {
rmSync(p.dir, { recursive: true, force: true });
- const why = `${r.why.trim() || "no DONE line"}: ${r.tail.split("\n").pop()}`;
+ const why = e.message.split("\n").join(" | ");
failed.push({ name: p.name, why });
log(` ! export failed: ${p.name} ${why}`);
}
diff --git a/scripts/lib/tb-ports.mjs b/scripts/lib/tb-ports.mjs
new file mode 100644
index 000000000..036a9095b
--- /dev/null
+++ b/scripts/lib/tb-ports.mjs
@@ -0,0 +1,125 @@
+// DevTools ports for the IDEs a run starts, claimed so that two runs never
+// share one.
+//
+// A run that starts an IDE on a port another IDE already holds would attach to
+// that IDE and operate it, so launchIde refuses a port something listens on.
+// Checking first is not enough when two runs start together: each finds the
+// port free, and the slower one is refused once the faster one's IDE is up.
+// So a port is claimed with a lock file before it is checked.
+//
+// The lock is %TEMP%\tb-ports\.lock and holds the claiming process's pid.
+// A lock whose process has ended is stale and is taken over; the run removes
+// its own when it exits. A port a process outside these tools listens on has no
+// lock, which is why each claimed port is also checked by binding it.
+//
+// Runs claim one at a time: a claim holds the folder %TEMP%\tb-ports\claiming,
+// which creating either makes or fails on, for the milliseconds it takes. Lock
+// files alone race: a lock is created empty and its pid written after, and a
+// run that reads it in between takes it for stale, removes it and claims the
+// same port.
+
+import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { portTaken, sleep } from "./tb-ide.mjs";
+import { alive } from "./tb-registry.mjs";
+
+const LOCKS = path.join(tmpdir(), "tb-ports");
+const CLAIMING = path.join(LOCKS, "claiming");
+// A claim takes milliseconds, so a claiming folder this old was left by a run
+// that ended during its claim.
+const CLAIMING_STALE_MS = 10_000;
+const claimed = new Set();
+let releaseOnExit = false;
+
+const lockFile = (port) => path.join(LOCKS, `${port}.lock`);
+
+// Runs `fn` while no other run is claiming.
+async function oneAtATime(fn) {
+ for (;;) {
+ try {
+ mkdirSync(CLAIMING);
+ break;
+ } catch (e) {
+ if (e.code !== "EEXIST") throw e;
+ }
+ try {
+ if (Date.now() - statSync(CLAIMING).mtimeMs > CLAIMING_STALE_MS)
+ rmSync(CLAIMING, { recursive: true, force: true });
+ } catch {
+ // removed since
+ }
+ await sleep(10);
+ }
+ try {
+ return await fn();
+ } finally {
+ rmSync(CLAIMING, { recursive: true, force: true });
+ }
+}
+
+// Writes the port's lock for this process; false when a live process holds it.
+// Called only inside oneAtATime.
+function lock(port) {
+ let pid = 0;
+ try {
+ pid = Number(readFileSync(lockFile(port), "utf8"));
+ } catch {
+ // no lock
+ }
+ if (pid && pid !== process.pid && alive(pid)) return false;
+ writeFileSync(lockFile(port), String(process.pid));
+ return true;
+}
+
+function unlock(port) {
+ claimed.delete(port);
+ try {
+ // Only this process's own lock: one another run took over is left alone.
+ if (Number(readFileSync(lockFile(port), "utf8")) === process.pid) rmSync(lockFile(port), { force: true });
+ } catch {
+ // already gone
+ }
+}
+
+/**
+ * Claim `count` ports, the first free ones from `from` upwards, for as long as
+ * this process runs. A port is free when no live process of these tools has
+ * claimed it and nothing listens on it.
+ *
+ * @param {number} count
+ * @param {object} o
+ * @param {number} o.from the first port to try
+ * @param {number} [o.tries] how many ports to try before giving up (default 200)
+ * @returns {Promise} in ascending order. Throws when fewer than
+ * `count` free ports are found.
+ */
+export async function claimPorts(count, { from, tries = 200 }) {
+ mkdirSync(LOCKS, { recursive: true });
+ if (!releaseOnExit) {
+ releaseOnExit = true;
+ process.on("exit", () => {
+ for (const port of [...claimed]) unlock(port);
+ });
+ }
+ return oneAtATime(async () => {
+ const ports = [];
+ for (let port = from; ports.length < count && port < from + tries && port <= 65535; port++) {
+ if (claimed.has(port) || !lock(port)) continue;
+ claimed.add(port);
+ if (await portTaken(port)) {
+ unlock(port);
+ continue;
+ }
+ ports.push(port);
+ }
+ if (ports.length < count) {
+ for (const port of ports) unlock(port);
+ throw new Error(
+ `found ${ports.length} free DevTools port(s) of the ${count} needed in ${from}-${from + tries - 1}: ` +
+ "the rest are claimed by another run or in use. Pass a different --port.",
+ );
+ }
+ return ports;
+ });
+}
diff --git a/scripts/lib/tb-probe.mjs b/scripts/lib/tb-probe.mjs
index bdb1568c5..31275f378 100644
--- a/scripts/lib/tb-probe.mjs
+++ b/scripts/lib/tb-probe.mjs
@@ -13,13 +13,15 @@
// line and column a diagnostic names is still the caller's.
// * The TbRun module, whose Out writes a line where the run can read it: the
// DEBUG CONSOLE in the IDE, and standard output in a built exe, where
-// Debug.Print writes nothing.
+// Debug.Print writes nothing. The wrapper tells it which: it sets IDE_FLAG
+// before it calls the probe, and a built exe never runs the wrapper.
import { MODIFIERS } from "./twin-declarations.mjs";
export const SENTINEL = "[tbrun] the probe returned";
export const WRAPPER_SUB = "tbrun_RunProbe";
export const TBRUN_FILE = "TbRun.twin";
+export const IDE_FLAG = "tbrun_InIDE";
// Lines the IDE prints after the probe's own run has returned. A probe that
// leaves a form loaded gets this one after SENTINEL (measured, BETA 983 and 995).
@@ -59,7 +61,9 @@ const TBRUN_MODULE_RE = /^[ \t]*(?:(?:Public|Private)[ \t]+)?Module[ \t]+TbRun\b
*/
export function wrapProbe(files) {
const out = [];
- if (!files.some((f) => TBRUN_MODULE_RE.test(f.text))) out.push({ name: TBRUN_FILE, text: TBRUN_MODULE });
+ // A tree's own TbRun module is left as it is, and the wrapper sets no flag in it.
+ const ours = !files.some((f) => TBRUN_MODULE_RE.test(f.text));
+ if (ours) out.push({ name: TBRUN_FILE, text: TBRUN_MODULE });
const none = (why) => ({ files: out, wrapped: null, why });
const hits = files.flatMap((f) =>
@@ -103,6 +107,7 @@ export function wrapProbe(files) {
const wrapper = [
`${indent}[RunAfterBuild]`,
`${indent}Public Sub ${WRAPPER_SUB}()`,
+ ...(ours ? [`${indent} TbRun.${IDE_FLAG} = True`] : []),
`${indent} ${sub}`,
`${indent} Debug.Print "${SENTINEL}"`,
`${indent}End Sub`,
@@ -119,17 +124,23 @@ export function wrapProbe(files) {
// Project-Types.md), so there Out writes UTF-8: to the file TBRUN_OUT names, or
// else to standard output. tbrun's --exe names a file, because it starts the
// exe on a private desktop with no handles inherited, so no pipe reaches it.
+// Out tells the IDE from the exe by IDE_FLAG, not by App.IsInIDE: App exists
+// only in a project that references the VB package, and in any other this
+// module would not compile (TB5079, Unrecognized symbol 'App').
const TBRUN_MODULE = `' Added by tbrun to the staged copy of a probe. TbRun.Out writes a line to the
' DEBUG CONSOLE in the IDE; in a built exe, as UTF-8, to the file the TBRUN_OUT
-' environment variable names, or else to standard output.
+' environment variable names, or else to standard output. tbrun's wrapper Sub
+' sets ${IDE_FLAG} before it calls the probe; a built exe runs Sub Main instead.
Module TbRun
+ Public ${IDE_FLAG} As Boolean
+
Private Declare PtrSafe Function GetStdHandle Lib "kernel32" (ByVal nStdHandle As Long) As LongPtr
Private Declare PtrSafe Function WriteFile Lib "kernel32" (ByVal hFile As LongPtr, ByVal lpBuffer As LongPtr, ByVal nNumberOfBytesToWrite As Long, ByRef lpNumberOfBytesWritten As Long, ByVal lpOverlapped As LongPtr) As Long
Private Declare PtrSafe Function WideCharToMultiByte Lib "kernel32" (ByVal CodePage As Long, ByVal dwFlags As Long, ByVal lpWideCharStr As LongPtr, ByVal cchWideChar As Long, ByVal lpMultiByteStr As LongPtr, ByVal cbMultiByte As Long, ByVal lpDefaultChar As LongPtr, ByVal lpUsedDefaultChar As LongPtr) As Long
Public Sub Out(ByVal Text As String)
- If App.IsInIDE Then
+ If ${IDE_FLAG} Then
Debug.Print Text
Exit Sub
End If
diff --git a/scripts/lib/tb-project.mjs b/scripts/lib/tb-project.mjs
index d0917c42f..7703bfe2d 100644
--- a/scripts/lib/tb-project.mjs
+++ b/scripts/lib/tb-project.mjs
@@ -1,8 +1,8 @@
// Staging an exported twinBASIC source tree as a project the harness can build.
//
// The harness never builds the tree it is given. It copies the tree, changes
-// settings in the copy, and packs the copy into a .twinproj with the compiler
-// executable's `import` verb. Two of those changes matter to every caller:
+// settings in the copy, and packs the copy into a .twinproj with impexp, in
+// process. Two of those changes matter to every caller:
//
// * project.buildPath becomes an explicit file. The default
// `${SourcePath}\Build\...` template makes the IDE open a native Save
@@ -15,10 +15,52 @@
//
// Rewriting the caller's own tree would do both, but a harness that edits the
// project it was pointed at is one nobody trusts with a real project.
+//
+// Not the compiler executable's `import` verb: it exits 999 with nothing
+// written on a tree holding an embedded package (twinbasic/twinbasic#841), and
+// 0 on every failure it reports. impexp also packs the files in alphabetical
+// order where the compiler packs them in reverse, and the order can decide
+// what compiles (the static-ctor-args entry in BUGS-TO-REPORT.md).
import { cpSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
-import { runCompiler } from "./tb-install.mjs";
+import { exportProject, importProject } from "../impexp.mjs";
+
+// What impexp refused, with its details, for an error message.
+const why = (e) => [e.message, ...(e.details ?? [])].join("\n");
+
+/**
+ * Pack a source tree into a project file, replacing the file.
+ *
+ * @returns {object} impexp's counts
+ */
+export function packTree(project, tree) {
+ try {
+ return importProject(project, tree, { overwrite: true });
+ } catch (e) {
+ throw new Error(`packing failed: ${why(e)}`);
+ }
+}
+
+/**
+ * Write a project's source into a folder, which should be new or empty.
+ *
+ * Export replaces the files it writes and leaves every other file there, and
+ * the next pack would put those back in, so a folder left holding one is an
+ * error rather than impexp's warning.
+ *
+ * @returns {object} impexp's counts
+ */
+export function unpackProject(project, folder) {
+ let r;
+ try {
+ r = exportProject(project, folder, { overwrite: true });
+ } catch (e) {
+ throw new Error(`unpacking failed: ${why(e)}`);
+ }
+ if (r.stale.length) throw new Error(`unpacking left files that are not in ${project}:\n${r.stale.join("\n")}`);
+ return r;
+}
/**
* Copy an exported source tree, change its settings, and pack it.
@@ -27,13 +69,12 @@ import { runCompiler } from "./tb-install.mjs";
* @param {string} o.src the exported tree: the folder holding Settings and Sources
* @param {string} o.stage where the copy goes; anything there is replaced
* @param {string} o.project the .twinproj to write
- * @param {string} o.compiler the compiler executable (tb-install's compilerExe)
* @param {object | ((original: object) => object)} [o.settings] settings to
* set in the copy, or a function from the tree's own settings to them
* @param {(stage: string) => void} [o.prepare] changes the copy before it is packed
* @returns {{original: object, settings: object}} the tree's settings, and the copy's
*/
-export function stageProject({ src, stage, project, compiler, settings = {}, prepare }) {
+export function stageProject({ src, stage, project, settings = {}, prepare }) {
rmSync(stage, { recursive: true, force: true });
cpSync(src, stage, { recursive: true });
const file = path.join(stage, "Settings");
@@ -41,12 +82,7 @@ export function stageProject({ src, stage, project, compiler, settings = {}, pre
const staged = { ...original, ...(typeof settings === "function" ? settings(original) : settings) };
writeFileSync(file, JSON.stringify(staged, null, "\t"), "utf8");
prepare?.(stage);
-
- // import's exit code does not say whether it worked -- 0 on the failures it
- // reports, 999 on a tree holding an embedded package -- so runCompiler reads
- // the output instead.
- const pack = runCompiler(compiler, ["import", project, stage, "--overwrite"]);
- if (!pack.done) throw new Error(`packing failed${pack.why}:\n${pack.tail}`);
+ packTree(project, stage);
return { original, settings: staged };
}
diff --git a/scripts/lib/tb-registry.mjs b/scripts/lib/tb-registry.mjs
index 1042de709..1a016b7c6 100644
--- a/scripts/lib/tb-registry.mjs
+++ b/scripts/lib/tb-registry.mjs
@@ -31,6 +31,10 @@
// (sweepArchitectureMemory says why), and a named project's is put back
// as it was, since a run can switch the target of the project it opens
// (restoreArchitectureMemory).
+// * The IDE's theme, one entry of its GENERAL options, is put back as it
+// was, and the other options are left as they are (restoreTheme): a
+// scenario switches the theme, and the IDE saves it where the user's own
+// IDE reads it.
//
// ONE PROCESS OWNS THIS PER RUN. check_examples and sweep_attributes build many
// projects at once, in one process (lib/tb-build.mjs, which never tidies); each
@@ -470,44 +474,88 @@ const ARCH_MEMORY = "targetArchitectureMemory";
/** A path as the harness compares those the IDE stores: backslashed and lower case. */
export const norm = (p) => String(p).split("/").join("\\").toLowerCase();
-// The build targets the IDE remembers, as an object, or null when there is no
-// value or it is not a JSON object -- which is not the harness's to repair.
-function readArchitectureMemory(root) {
- const now = request({ op: "readValue", key: `${root}\\IDESettings`, name: ARCH_MEMORY });
+// An IDESettings value holding a JSON object, as that object, or null when
+// there is no value or it is not a JSON object -- which is not the harness's
+// to repair.
+function readSettingObject(root, name) {
+ const now = request({ op: "readValue", key: `${root}\\IDESettings`, name });
if (!now.exists) return null;
- let memory;
+ let object;
try {
- memory = JSON.parse(now.data);
+ object = JSON.parse(now.data);
} catch {
return null;
}
- if (!memory || typeof memory !== "object" || Array.isArray(memory)) return null;
- return { data: now.data, memory };
-}
-
-// Change the remembered build targets with `edit`, which is handed the object
-// and returns how many entries it changed. The object is edited here rather
-// than in PowerShell, and written back with JSON.stringify, which is how the
-// IDE writes it, so the other entries keep their exact text and order. The
-// write is refused if the value changed after it was read -- an IDE switching
-// a target of its own meanwhile -- and the edit is then made again on what is
-// there now.
-function editArchitectureMemory(root, edit) {
+ if (!object || typeof object !== "object" || Array.isArray(object)) return null;
+ return { data: now.data, object };
+}
+
+// Change an IDESettings JSON value with `edit`, which is handed the object and
+// returns how many entries it changed. The object is edited here rather than
+// in PowerShell, and written back with JSON.stringify and the indent the IDE
+// writes that value with, so the other entries keep their exact text and
+// order. The write is refused if the value changed after it was read -- an IDE
+// saving it meanwhile -- and the edit is then made again on what is there now.
+function editSettingObject(root, name, edit, indent = undefined) {
for (let attempt = 0; attempt < 3; attempt++) {
- const now = readArchitectureMemory(root);
+ const now = readSettingObject(root, name);
if (!now) return 0;
- const changes = edit(now.memory);
+ const changes = edit(now.object);
if (!changes) return 0;
const w = request({
op: "writeValueIf",
key: `${root}\\IDESettings`,
- name: ARCH_MEMORY,
+ name,
expected: now.data,
- data: JSON.stringify(now.memory),
+ data: JSON.stringify(now.object, undefined, indent),
});
if (w.written) return changes;
}
- throw new Error(`the IDE's ${ARCH_MEMORY} kept changing while it was being tidied`);
+ throw new Error(`the IDE's ${name} kept changing while it was being tidied`);
+}
+
+// The build targets the IDE remembers; it writes the value unindented.
+const readArchitectureMemory = (root) => {
+ const now = readSettingObject(root, ARCH_MEMORY);
+ return now && { data: now.data, memory: now.object };
+};
+const editArchitectureMemory = (root, edit) => editSettingObject(root, ARCH_MEMORY, edit);
+
+// The IDE's options, which it writes with JSON.stringify(options, undefined,
+// "\t"), and the one of them a run can change: the theme, which an add-in
+// scenario switches to see what an add-in does on a theme change.
+const OPTIONS = "GENERAL";
+const THEME = "colorTheme";
+
+/**
+ * Record the IDE's theme (colorTheme in its GENERAL options), for restoreTheme.
+ * The IDE saves the options whenever the theme changes, and every IDE of every
+ * install reads them, so a run that switches the theme switches the user's.
+ */
+export function snapshotTheme({ root = IDE_SETTINGS_KEY } = {}) {
+ const now = readSettingObject(root, OPTIONS);
+ return { root, options: !!now, had: !!now && THEME in now.object, value: now?.object[THEME] };
+}
+
+/**
+ * Put back the theme a snapshot recorded, leaving every other option as the
+ * IDE left it.
+ *
+ * @returns {number} 1 if it was written, else 0
+ */
+export function restoreTheme(snap) {
+ if (!snap?.options) return 0;
+ return editSettingObject(
+ snap.root,
+ OPTIONS,
+ (options) => {
+ if (snap.had ? options[THEME] === snap.value : !(THEME in options)) return 0;
+ if (snap.had) options[THEME] = snap.value;
+ else delete options[THEME];
+ return 1;
+ },
+ "\t",
+ );
}
/**
@@ -664,6 +712,12 @@ export function startTidy({ paths = [], prefixes = [], root = IDE_SETTINGS_KEY,
// After the sweep, so a named project inside a swept folder is not given back
// an entry the sweep has just deleted.
tidy.targets = snapshotTargets(paths, root);
+ try {
+ tidy.theme = snapshotTheme({ root });
+ } catch (e) {
+ console.error(`warning: the IDE's theme will not be put back after this run: ${e.message}`);
+ tidy.theme = null;
+ }
return tidy;
}
@@ -672,7 +726,7 @@ export function startTidy({ paths = [], prefixes = [], root = IDE_SETTINGS_KEY,
* the run has exited -- shutdownIde waits for that.
*
* @returns {{projectState: number, recentlyOpened: number, association: number,
- * architecture: number | null} | null}
+ * architecture: number | null, theme: number | null} | null}
*/
export function finishTidy(tidy) {
if (!tidy) return null;
@@ -697,7 +751,13 @@ export function finishTidy(tidy) {
}
const swept = sweepTargets(tidy.prefixes, tidy.root);
const restored = restoreTargets(tidy.targets);
- return { ...done, architecture: swept === null || restored === null ? null : swept + restored };
+ let theme = null;
+ try {
+ theme = restoreTheme(tidy.theme);
+ } catch (e) {
+ console.error(`warning: could not put back the IDE's theme: ${e.message}`);
+ }
+ return { ...done, architecture: swept === null || restored === null ? null : swept + restored, theme };
}
// Whether any value in a key snapshot names a path inside the temp folder.
diff --git a/scripts/lib/vb6.mjs b/scripts/lib/vb6.mjs
index 466caec40..b7cc09659 100644
--- a/scripts/lib/vb6.mjs
+++ b/scripts/lib/vb6.mjs
@@ -66,6 +66,21 @@
// sample, as the twinBASIC dispatcher does. A twinBASIC `Declare PtrSafe` is not VB6:
// declaresForVb6 drops PtrSafe and reads LongPtr as Long.
//
+// 11. A VB6 EXE THAT DIES OF AN ACCESS VIOLATION EXITS WITH CODE 0. Windows Error
+// Reporting writes an "Application Error" record (event 1000) to the Application log,
+// naming the exe's path and the exception code, but GetExitCodeProcess reads 0, so the
+// exit code says the run succeeded. runRepro and runExe look for that record
+// (recordedFault) and report the fault beside the exit code. Every build is in a
+// folder of its own, so the path names one run. THE RECORD IS WRITTEN ONLY FOR AN EXE
+// STARTED THROUGH tb-launch.ps1 (launchOnDesktop: runRepro, and runExe for a form
+// project). The same exe spawned by Node directly faults with the same exit code 0 and
+// leaves no record (2026-10-04, both for the Advise crash in MSVBVM60.DLL and for
+// RtlMoveMemory from address 0 in ntdll.dll); Node's error mode, which a child
+// inherits, is the likely cause. So runBatch also says which sample the exe ended in
+// before the time limit: one that began, never ended, and was not ended by the time
+// limit died there, record or not. (A Probe.exe that called Advise with a null sink on a VB6
+// class's connection point, 2026-10-04: c0000005 in MSVBVM60.DLL, exit code 0.)
+//
// The probes for the rewrite and the translation are in vb6Probes() at the end.
import { execFileSync, spawn } from "node:child_process";
@@ -707,7 +722,8 @@ export async function make(vb6, dir, { timeoutMs = 120000, project = PROJECT } =
* as its command line; a reproducer's is `project: "Probe"`, which writes
* `outName` and takes `args` (none by default).
*
- * @returns {Promise<{lines: string[], status: number|null, timedOut: boolean, error?: string}>}
+ * @returns {Promise<{lines: string[], status: number|null, timedOut: boolean, fault: {code: string, module: string, offset: string} | null, error?: string}>}
+ * `fault` is what recordedFault found for the exe (see 11 at the top)
*/
export async function runExe(
dir,
@@ -715,11 +731,14 @@ export async function runExe(
) {
const outPath = path.join(dir, outName);
rmSync(outPath, { force: true });
+ const exe = path.join(dir, `${project}.exe`);
+ const since = Date.now();
const r = desktop
- ? await runOnDesktop(path.join(dir, `${project}.exe`), args.join(" "), { timeoutMs })
- : await runLimited(path.join(dir, `${project}.exe`), args, { cwd: dir, timeoutMs });
+ ? await runOnDesktop(exe, args.join(" "), { timeoutMs })
+ : await runLimited(exe, args, { cwd: dir, timeoutMs });
const lines = existsSync(outPath) ? splitLines(decodeAnsi(readFileSync(outPath))) : [];
- return { lines, status: r.status, timedOut: r.timedOut, error: r.error };
+ const fault = r.timedOut || r.error ? null : recordedFault(exe, since);
+ return { lines, status: r.status, timedOut: r.timedOut, fault, error: r.error };
}
// ------------------------------------------------------- a reproducer's project
@@ -807,20 +826,72 @@ export function unattended(vbp, on = true) {
return `${kept.join("\r\n")}\r\n`;
}
+const XML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
+
+/**
+ * The fault that Windows Error Reporting recorded in the Application log for `exe`, a
+ * full path, at or after `since` (a Date.now() value taken before the exe started), or null when there is none or the
+ * log cannot be read. See 11 at the top: a VB6 exe that faults exits with code 0, and
+ * this record is what says it faulted.
+ *
+ * @returns {{code: string, module: string, offset: string} | null}
+ * the exception code, the faulting module and the offset in it, in hex as the record has them
+ */
+export function recordedFault(exe, since) {
+ const ms = Math.max(0, Date.now() - since) + 5000;
+ const query = `*[System[Provider[@Name='Application Error'] and (EventID=1000) and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`;
+ let xml;
+ try {
+ xml = execFileSync("wevtutil", ["qe", "Application", `/q:${query}`, "/f:xml"], {
+ encoding: "utf8",
+ windowsHide: true,
+ });
+ } catch {
+ return null;
+ }
+ const want = exe.toLowerCase();
+ for (const event of xml.split("")) {
+ // The query's window reaches back before `since`, and runBatch runs one exe again after a
+ // sample that did not return: a record from before this run is another run's.
+ const at = /([^<]*)<\/Data>/g)].map((m) =>
+ m[1].replace(/&(amp|lt|gt|quot|apos);/g, (_, e) => XML_ENTITIES[e]),
+ );
+ if (data[10]?.toLowerCase() === want) return { code: data[6], module: data[3], offset: data[7] };
+ }
+ return null;
+}
+
+/** A fault from recordedFault, as a report says it. */
+export const faultText = ({ code, module, offset }) => `exception 0x${code} in ${module} at offset 0x${offset}`;
+
+/** What a report says of a sample in runBatch's `died`. */
+export const diedText = ({ status, fault }) =>
+ fault
+ ? `ended the exe with ${faultText(fault)}`
+ : `ended the exe, which exited with code ${status} and no fault in the event log`;
+
/**
* Run a reproducer's built exe on a private desktop, inside a kill-on-close job,
* for at most `timeoutMs`, and read the out.txt it wrote. `env` adds variables to the exe's
* environment, which is the caller's otherwise.
*
- * @returns {Promise<{lines: string[], status: number|null, timedOut: boolean}>}
+ * @returns {Promise<{lines: string[], status: number|null, timedOut: boolean, fault: {code: string, module: string, offset: string} | null}>}
+ * `fault` is what recordedFault found for the exe, whatever its exit code
*/
async function runReproExe(work, timeoutMs, env = {}) {
const outPath = path.join(work, REPRO_OUT);
rmSync(outPath, { force: true });
+ const exe = path.join(work, `${REPRO_PROJECT}.exe`);
+ const since = Date.now();
let run;
try {
run = await launchOnDesktop({
- exe: path.join(work, `${REPRO_PROJECT}.exe`),
+ exe,
desktop: `bugrepro-vb6-${process.pid}`,
env: { ...process.env, ...env },
});
@@ -841,15 +912,17 @@ async function runReproExe(work, timeoutMs, env = {}) {
}
const status = await run.exited;
const lines = existsSync(outPath) ? splitLines(decodeAnsi(readFileSync(outPath))) : [];
- return { lines, status: timedOut ? null : status, timedOut };
+ const fault = timedOut ? null : recordedFault(exe, since);
+ return { lines, status: timedOut ? null : status, timedOut, fault };
}
/**
* Build and run the VB6 project in `dir` (a reproducer's vb6/ folder), in a copy
* of its sources under the OS temp folder, which is removed unless `keep`.
*
- * @returns {Promise<{built: boolean, log: string, lines: string[], status: number|null, timedOut: boolean, work: string, kept: boolean}>}
- * `lines` is what out.txt held; a VB6 that did not finish building throws
+ * @returns {Promise<{built: boolean, log: string, lines: string[], status: number|null, timedOut: boolean, fault: {code: string, module: string, offset: string} | null, work: string, kept: boolean}>}
+ * `lines` is what out.txt held, `fault` the exe's fault from the event log (see 11 at the
+ * top); a VB6 that did not finish building throws
*/
export async function runRepro(vb6, dir, { timeoutMs = 30000, keep = false, env = {} } = {}) {
const work = mkdtempSync(path.join(tmpdir(), "bugrepro-vb6-"));
@@ -862,10 +935,19 @@ export async function runRepro(vb6, dir, { timeoutMs = 30000, keep = false, env
if (made.timedOut || made.error) {
throw new Error(`VB6 did not finish building${made.error ? `: ${made.error}` : " in time"}\n${made.log}`);
}
- const result = { built: made.built, log: made.log, lines: [], status: null, timedOut: false, work, kept: keep };
+ const result = {
+ built: made.built,
+ log: made.log,
+ lines: [],
+ status: null,
+ timedOut: false,
+ fault: null,
+ work,
+ kept: keep,
+ };
if (!made.built) return result;
const ran = await runReproExe(work, Math.min(timeoutMs, 2147483647), env);
- return { ...result, lines: ran.lines, status: ran.status, timedOut: ran.timedOut };
+ return { ...result, lines: ran.lines, status: ran.status, timedOut: ran.timedOut, fault: ran.fault };
} finally {
if (!keep) rmSync(work, { recursive: true, force: true });
}
@@ -953,12 +1035,16 @@ export async function buildBatch(vb6, dir, modules, { timeoutMs, support = [] }
* @param {number} count samples in the built project
* @param {{timeoutMs?: number, desktop?: boolean}} o `desktop` runs the exe on a private
* desktop, for a project with a form
- * @returns {Promise<{items: ReturnType["items"], hung: number[]}>}
- * `hung` lists the samples that began and never ended
+ * @returns {Promise<{items: ReturnType["items"], hung: number[], died: Map}>}
+ * `hung` lists the samples that began and never ended. `died` holds those of them
+ * during which the exe ended by itself, before the time limit: its exit code, and the
+ * fault the event log recorded, when there is one (see 11 at the top). The rest of
+ * `hung` were still running at the time limit.
*/
export async function runBatch(dir, count, { timeoutMs, desktop = false } = {}) {
const items = Array.from({ length: count }, () => ({ began: false, ended: false, output: [], error: null }));
const hung = [];
+ const died = new Map();
let start = 0;
while (start < count) {
const r = await runExe(dir, { start, timeoutMs, desktop });
@@ -970,15 +1056,21 @@ export async function runBatch(dir, count, { timeoutMs, desktop = false } = {})
if (parsed.done) break;
// The run stopped before it finished: the last sample to begin is the one it was in.
const last = parsed.items.map((it) => it.began && !it.ended).lastIndexOf(true);
+ // An exe that ended before the time limit, in the middle of a sample, died in it.
+ const end = r.timedOut ? null : { status: r.status, fault: r.fault };
if (last < start) {
// Nothing began: the exe never got going (it was killed, or it died), and rerunning cannot help.
- if (!parsed.items.some((it) => it.began)) hung.push(start);
+ if (!parsed.items.some((it) => it.began)) {
+ hung.push(start);
+ if (end) died.set(start, end);
+ }
break;
}
hung.push(last);
+ if (end) died.set(last, end);
start = last + 1;
}
- return { items, hung };
+ return { items, hung, died };
}
// -------------------------------------------------------------------- probes
diff --git a/scripts/sweep_attributes.mjs b/scripts/sweep_attributes.mjs
index 643b7deda..4e3064c07 100644
--- a/scripts/sweep_attributes.mjs
+++ b/scripts/sweep_attributes.mjs
@@ -101,7 +101,8 @@ import {
import { SITE_LIST, renderSite } from "./lib/attribute-sites.mjs";
import { compileProject } from "./lib/tb-build.mjs";
import { sleep, wantShow } from "./lib/tb-ide.mjs";
-import { compilerExe, buildNumber, findIde, runCompiler } from "./lib/tb-install.mjs";
+import { buildNumber, findIde } from "./lib/tb-install.mjs";
+import { packTree } from "./lib/tb-project.mjs";
import { finishTidy, startTidy } from "./lib/tb-registry.mjs";
import {
CliError,
@@ -276,7 +277,6 @@ if (!values.dryRun && (!IDE || !existsSync(IDE))) {
"pass --ide , set TB_IDE, or unpack a twinBASIC_IDE_BETA_ folder on your Desktop",
);
}
-const COMPILER = IDE ? compilerExe(IDE) : null;
const SHOW = wantShow({ show: values.show, hide: values.hide });
// ------------------------------------------------------------ the universe
@@ -383,7 +383,7 @@ function stageBatch(probes, lane) {
put("_ProbeMain.twin", MAIN_TWIN);
put("_ProbeFactory.twin", PROBE_FACTORY_TWIN);
put("_ProbeSupport.twin", SUPPORT_TWIN);
- // `import` packs the whole tree, so what [CustomControl] and [PopulateFrom]
+ // The pack takes the whole tree, so what [CustomControl] and [PopulateFrom]
// point at has to be in it before the pack.
writeProbeResourcesSync(dir);
const files = new Map();
@@ -392,9 +392,7 @@ function stageBatch(probes, lane) {
files.set(`${p.tag}.twin`.toLowerCase(), p);
}
const proj = path.join(lane.work, `b${index}.twinproj`);
- // Windows paths throughout: the compiler prefixes \\?\, which takes no forward slash.
- const pack = runCompiler(COMPILER, ["import", proj.split("/").join("\\"), dir.split("/").join("\\"), "--overwrite"]);
- if (!pack.done) throw new Error(`packing failed${pack.why}:\n${pack.tail}`);
+ packTree(proj, dir);
return { proj, dir, files };
}
diff --git a/scripts/tbbuild.mjs b/scripts/tbbuild.mjs
index 47ea11518..033f14c15 100644
--- a/scripts/tbbuild.mjs
+++ b/scripts/tbbuild.mjs
@@ -76,10 +76,10 @@ import {
refuseTogether,
withUsageError,
} from "../lib/cli.mjs";
-import { compilerExe, findIde, runCompiler } from "./lib/tb-install.mjs";
+import { findIde } from "./lib/tb-install.mjs";
import { compileProject } from "./lib/tb-build.mjs";
import { COMPILE_TIMEOUT, keptIdeLines, TARGETS, summaryLine, wantShow } from "./lib/tb-ide.mjs";
-import { laneProjectId, stageProject } from "./lib/tb-project.mjs";
+import { laneProjectId, stageProject, unpackProject } from "./lib/tb-project.mjs";
import { finishTidy, startTidy } from "./lib/tb-registry.mjs";
exitOnCrash();
@@ -230,26 +230,25 @@ if (build) {
rmSync(work, { recursive: true, force: true });
const out = path.join(work, "out");
mkdirSync(out, { recursive: true });
- const compiler = compilerExe(IDE);
- if (!existsSync(compiler)) die(2, `no compiler beside the IDE at ${compiler}`);
target = path.join(work, "tbbuild-probe.twinproj");
// The IDE opens a project in place, and a build writes where the project's
// buildPath says. The packed project's is the template's, whose Save dialog a
// private desktop hides, so the project is exported, given an explicit file and
- // a project id of its own, and packed again (lib/tb-project.mjs).
- // Backslashes throughout and a trailing one on the folder: `export` prefixes
- // \\?\ to what it is given and cannot create or find a folder named with
- // forward slashes. Its exit code does not say whether it worked: runCompiler.
+ // a project id of its own, and packed again (lib/tb-project.mjs). Both
+ // directions run in process, through impexp, and the folder it exports into
+ // is new: the work folder was emptied above.
const src = path.join(work, "src");
- const exported = runCompiler(compiler, ["export", path.resolve(proj), src + path.sep, "--overwrite"]);
- if (!exported.done) die(2, `exporting ${proj} failed${exported.why}:\n${exported.tail}`);
+ try {
+ unpackProject(path.resolve(proj), src);
+ } catch (e) {
+ die(2, `exporting ${proj} failed:\n${e.message}`);
+ }
// Under --llvm the options are the run's and the exe's alike, as tbrun's are.
try {
stageProject({
src,
stage: path.join(work, "stage"),
project: target,
- compiler,
settings: {
"project.buildPath": path.join(out, "${ProjectName}_${Architecture}.${FileExtension}"),
"project.id": laneProjectId(3, port),
diff --git a/scripts/tbrun.mjs b/scripts/tbrun.mjs
index 01cb96fa1..2dbb75158 100644
--- a/scripts/tbrun.mjs
+++ b/scripts/tbrun.mjs
@@ -131,7 +131,7 @@ import {
refuseTogether,
withUsageError,
} from "../lib/cli.mjs";
-import { compilerExe, findIde } from "./lib/tb-install.mjs";
+import { findIde } from "./lib/tb-install.mjs";
import {
COMPILE_TIMEOUT,
TARGETS,
@@ -188,7 +188,7 @@ Exit codes:
0 the probe ran and its output was captured
1 the project has compile errors; the diagnostics are printed
2 a refused command line (a source folder that is missing or has no Settings file
- included), no IDE or compiler, an IDE that did not start, a compile that never
+ included), no IDE, an IDE that did not start, a compile that never
settled, a build that failed after a clean compile, a probe that never ran or
stopped at a procedure that failed code generation, an --llvm run on a
Community or Personal licence, an --exe run with no exe built, or a crash
@@ -252,7 +252,7 @@ if (!existsSync(srcDir) || !statSync(srcDir).isDirectory()) {
`not a directory: ${srcDir}\n` +
`tbrun takes an exported source tree (the folder holding Sources/ and Settings), ` +
`because it has to pin the build path before packing. Export one with:\n` +
- ` twinBASIC_win32.exe export \\ --overwrite`,
+ ` node scripts/impexp.mjs export `,
);
}
@@ -267,14 +267,12 @@ const REAP_IMAGES = ["excel", "winword", "powerpnt", "msaccess", "outlook", "one
// ---------------------------------------------------------------- the IDE
-// One install packs the tree and builds it. --ide, then TB_IDE, then the
-// newest BETA on the Desktop, as for every other tool here.
+// One install builds the tree. --ide, then TB_IDE, then the newest BETA on the
+// Desktop, as for every other tool here.
const ide = findIde(values.ide || undefined);
if (!ide || !existsSync(ide)) {
die(2, "no twinBASIC IDE found. Pass --ide or set TB_IDE.");
}
-const COMPILER = compilerExe(ide);
-if (!existsSync(COMPILER)) die(2, `no compiler beside the IDE at ${COMPILER}`);
// ------------------------------------------------- pin the build output (1)
@@ -340,7 +338,6 @@ try {
src: srcDir,
stage,
project: projPath,
- compiler: COMPILER,
settings: {
"project.buildPath": buildPath,
"project.id": laneProjectId(0, port),
diff --git a/scripts/try_help_addin.mjs b/scripts/try_help_addin.mjs
new file mode 100644
index 000000000..c876ce401
--- /dev/null
+++ b/scripts/try_help_addin.mjs
@@ -0,0 +1,192 @@
+#!/usr/bin/env node
+// Open an IDE on your own desktop with the help add-in (add-in/) loaded, to try
+// it by hand. The IDE is set up as a lane of addin-test sets one up
+// (lib/tb-lane.mjs), and the tool waits until it is closed.
+//
+// node scripts/try_help_addin.mjs [options]
+//
+// try-help-addin.bat is the wrapper. Not a gate, for the reasons addin-test.bat
+// is not one: it needs Windows and a twinBASIC install.
+//
+// It keeps the rules addin-test keeps, so that trying the add-in changes
+// nothing the user owns:
+//
+// * The add-in is built into a copy of the install made in the work folder,
+// never into the install's addins\ or %APPDATA%\twinBASIC\addins\, and the
+// IDE gets an APPDATA of its own there, so none of the user's add-ins loads.
+// * The IDE's registry entries and the add-in's SaveSetting key (tbDocsHelp)
+// are recorded first and put back once the IDE is closed.
+// * The test switch is on, as in every IDE the harness starts, so Open in
+// browser prints `open ` to the DEBUG CONSOLE and starts nothing. The
+// pane's pages come from the built site, docs/_site, served on localhost.
+//
+// Unlike the lanes, the IDE is always on the user's desktop: there is nothing
+// to try on a private one. The IDE that builds the add-in is not: it runs on a
+// private desktop, as a lane's does, so it cannot flash up or take the focus.
+
+import { existsSync, mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { createStaticHandler } from "../builder/static-files.mjs";
+import { die, exitOnCrash, numberOption, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs";
+import { REPO_ROOT } from "../lib/repo-paths.mjs";
+import { serveLoopback } from "../test/addin/pages.mjs";
+import { removeTree } from "./lib/tb-ide-copy.mjs";
+import { shutdownIde } from "./lib/tb-ide.mjs";
+import { findIde } from "./lib/tb-install.mjs";
+import { Lane } from "./lib/tb-lane.mjs";
+import { claimPorts } from "./lib/tb-ports.mjs";
+import { alive, finishTidy, restoreKeys, settingsKey, snapshotKeys, startTidy } from "./lib/tb-registry.mjs";
+
+const SETTINGS = "tbDocsHelp";
+const ADDIN = path.join(REPO_ROOT, "add-in");
+const HOST = path.join(REPO_ROOT, "test", "addin", "helphost");
+const SITE = path.join(REPO_ROOT, "docs", "_site");
+
+const USAGE = `usage: node scripts/try_help_addin.mjs [--project ] [--port N] [--ide ] [-h, --help]
+
+Opens an IDE on your desktop with the help add-in in add-in/ built and loaded,
+and waits until you close it. The add-in goes into a copy of the install in the
+temp folder, the IDE gets an APPDATA of its own, and the IDE's registry entries
+and the add-in's saved settings are put back afterwards. Open in browser prints
+the URL to the DEBUG CONSOLE; the pane's pages come from docs/_site, so run
+build.bat first.
+
+ --project the exported project to open (default test/addin/helphost,
+ the help lane's host); it is opened as a staged copy, so
+ edits made in the IDE are not kept
+ --port the IDE's DevTools port: the first free one from n (default
+ 9590), passing over one another run has claimed or
+ something listens on
+ --ide the twinBASIC.exe to copy (default: $TB_IDE, else the
+ newest twinBASIC_IDE_BETA_* on the Desktop)
+ -h, --help print this text and exit
+
+Exit codes:
+ 0 the IDE was closed, and the registry is as it was found
+ 1 the add-in did not build, or the project does not compile
+ 2 the tool could not run: a refused command line, no IDE, no built site, a
+ registry it could not record, or a crash
+ 3 the registry or the work folder was not put back; see the lines above`;
+
+const { values } = withUsageError(() =>
+ parseCli(process.argv.slice(2), {
+ options: {
+ project: { type: "string" },
+ port: { type: "string" },
+ ide: { type: "string" },
+ help: { type: "boolean", short: "h", default: false },
+ },
+ stopAt: ["help"],
+ }),
+);
+if (values.help) printHelpAndExit(USAGE);
+const firstPort = withUsageError(() =>
+ numberOption(values.port ?? "9590", { option: "--port", integer: true, min: 1, max: 65535 }),
+);
+const project = path.resolve(values.project ?? HOST);
+
+const ide = findIde(values.ide || undefined);
+if (!ide || !existsSync(ide)) {
+ die(
+ 2,
+ "no twinBASIC IDE found: pass --ide , set TB_IDE, " +
+ "or unpack a twinBASIC_IDE_BETA_ folder on your Desktop",
+ );
+}
+if (!existsSync(path.join(project, "Settings"))) die(2, `${project} is not an exported project: it has no Settings`);
+if (!existsSync(path.join(SITE, "tB", "symbols.json"))) die(2, `no built site in ${SITE}: run build.bat first`);
+let port;
+try {
+ [port] = await claimPorts(1, { from: firstPort });
+} catch (e) {
+ die(2, e.message);
+}
+
+const work = path.join(tmpdir(), "tbhelp-try", String(port));
+try {
+ removeTree(work);
+} catch (e) {
+ die(2, `${work} could not be emptied (${e.code}): is an IDE from an earlier run still open?`);
+}
+mkdirSync(work, { recursive: true });
+const tidy = startTidy({ prefixes: [work] });
+if (!tidy) die(2, "could not record the registry, so it could not be put back afterwards");
+const settingsBefore = snapshotKeys([settingsKey(SETTINGS)]);
+
+const lane = new Lane({ name: "try", port, work, ide, show: true });
+let server = null;
+let stopping = false;
+
+// Puts everything back; returns the problems, as lines.
+async function putBack() {
+ const problems = [];
+ try {
+ await lane.close();
+ } catch (e) {
+ console.error(e.message);
+ }
+ server?.close();
+ if (!finishTidy(tidy)) problems.push("the IDE's registry entries could not be put back (see the warning above)");
+ try {
+ restoreKeys(settingsBefore);
+ } catch (e) {
+ problems.push(`the ${SETTINGS} settings could not be put back: ${e.message}`);
+ }
+ try {
+ removeTree(work);
+ } catch (e) {
+ problems.push(`${work} could not be removed (${e.code})`);
+ }
+ return problems;
+}
+
+exitOnCrash(() => {
+ console.error("putting the registry back after the crash");
+ shutdownIde(lane.run);
+ finishTidy(tidy);
+ restoreKeys(settingsBefore);
+});
+// Ctrl+C ends the wait, and the IDE is closed and everything put back as on a
+// normal close. A second Ctrl+C is Node's own.
+process.once("SIGINT", () => {
+ stopping = true;
+ console.log("closing the IDE");
+});
+
+// The exit code for an error from one step: 1 when it is the add-in's or the
+// project's own fault, 2 when the tool failed.
+let failed = 0;
+let step = "the site";
+try {
+ const files = createStaticHandler(SITE);
+ server = await serveLoopback((req, res) => files(req, res));
+ const origin = `http://localhost:${server.port}`;
+ step = "build";
+ console.log(`building ${path.relative(REPO_ROOT, ADDIN)} into a copy of ${ide}`);
+ await lane.addAddin(ADDIN, { show: false });
+ if (!stopping) {
+ step = "open";
+ console.log(`opening ${project}`);
+ await lane.open(project, { env: { TB_DOCS_HELP_SITE: origin } });
+ step = "wait";
+ const pid = lane.run.pid;
+ console.log(`the IDE is open (pid ${pid}), its pages from ${origin}; close it, or press Ctrl+C here, to end`);
+ while (!stopping && alive(pid)) await new Promise((r) => setTimeout(r, 1000));
+ }
+} catch (e) {
+ console.error(e.message);
+ // buildAddin's exitCode is tbbuild's: 1 compile errors, 4 the compiler
+ // crashed. Lane.open says "does not compile" for a project with errors.
+ const own =
+ (step === "build" && (e.exitCode === 1 || e.exitCode === 4)) ||
+ (step === "open" && / does not compile\n/.test(e.message));
+ failed = own ? 1 : 2;
+}
+const problems = await putBack();
+if (problems.length) {
+ console.error(problems.join("\n"));
+ process.exit(3);
+}
+console.log("the registry and the add-in's settings are as they were found");
+process.exit(failed);
diff --git a/scripts/vb6run.mjs b/scripts/vb6run.mjs
index 7a5d8d584..eb0ba09b5 100644
--- a/scripts/vb6run.mjs
+++ b/scripts/vb6run.mjs
@@ -42,6 +42,7 @@ import {
buildBatch,
declarationsModule,
declaresMain,
+ diedText,
findVb6,
makeWorkDir,
moduleFor,
@@ -83,8 +84,8 @@ generated Sub Main opens, because Debug.Print writes nothing in a compiled exe.
same, differs (the lines that differ, page against VB6), not VB6
(VB6 refuses to compile it, or the files of its group; most
twinBASIC syntax ends here, and it is informational), error (a
- run-time error, or it did not return), refused (the sample cannot
- be run)
+ run-time error, the exe ended during it, or it did not return),
+ refused (the sample cannot be run)
--only with --docs, the pages whose path under docs/ matches this regular
expression
--vb6 VB6.EXE (default: $VB6_EXE, else VB98\\VB6.EXE under Program Files
@@ -97,7 +98,10 @@ generated Sub Main opens, because Debug.Print writes nothing in a compiled exe.
Exit codes:
0 the sample ran; with --docs, no fence differs and none raised an error
- 1 a VB6 compile error, a run-time error, or a sample that did not return; with
+ 1 a VB6 compile error, a run-time error, a sample during which the exe ended
+ (a VB6 exe that dies of a fault can exit with code 0; the fault the
+ Application event log records is named when there is one), or a sample that
+ did not return; with
--docs, at least one fence differs or raised an error
2 the harness could not run: a refused command line, a file that is missing, a
sample that is refused, no VB6 (give --vb6 or set VB6_EXE), VB6 failing to
@@ -188,7 +192,12 @@ async function sample() {
const item = run.items[0];
result.output = item.output;
let code = 0;
- if (run.hung.length || !item.began) {
+ const died = run.died.get(0);
+ if (died) {
+ result.state = "crashed";
+ result.error = { message: `the sample ${diedText(died)}` };
+ code = 1;
+ } else if (run.hung.length || !item.began) {
result.state = item.began ? "did not return" : "not run";
result.error = { message: `${result.state} (time limit ${timeoutMs / 1000} s)` };
code = 1;
@@ -276,7 +285,10 @@ async function docs() {
ran.forEach((r, i) => {
const item = run.items[i];
const f = r.fence;
- if (run.hung.includes(i)) {
+ if (run.died.has(i)) {
+ r.state = "error";
+ r.detail.push(diedText(run.died.get(i)));
+ } else if (run.hung.includes(i)) {
r.state = "error";
r.detail.push(`did not return within ${timeoutMs / 1000} s`);
} else if (!item.began) {
diff --git a/test.bat b/test.bat
index 588470bb7..b66c5044e 100644
--- a/test.bat
+++ b/test.bat
@@ -83,6 +83,12 @@ node --test test/png.test.mjs
@rem No tree, no browser, well under a second.
node --test test/example-batches.test.mjs
@if errorlevel 1 goto :fail
+@rem Unit tests for scripts/lib/tb-ports.mjs, which claims the DevTools
+@rem ports the harness's IDEs use: two claims at once are disjoint, a port
+@rem in use and a live lock are passed over, a stale lock is taken over.
+@rem Starts no IDE. No tree, no browser, about 2 s.
+node --test test/ports.test.mjs
+@if errorlevel 1 goto :fail
@rem A regex that backtracks exponentially is a hang waiting for the
@rem right input, and nothing that reads the site can see it: the corpus
@rem passes until some page happens to contain the trigger, and then the
diff --git a/test/addin/help.test.mjs b/test/addin/help.test.mjs
new file mode 100644
index 000000000..19753344c
--- /dev/null
+++ b/test/addin/help.test.mjs
@@ -0,0 +1,453 @@
+// The help add-in, add-in/, increments 1 to 3 of Stage 4 in WIP.HelpAddin.md:
+// F1 shows the page for the selection or for the name under the cursor in the
+// help pane, whose search box and results list find any page of the index;
+// for the name under the cursor, the compiler's hover says where it is
+// declared.
+//
+// The add-in is built as committed, with its copy of the symbol index,
+// add-in/Resources/SYMBOLS/symbols.json, embedded as a resource. The pane's
+// frame loads the built site, docs/_site, which this file serves on localhost
+// and gives the add-in as TB_DOCS_HELP_SITE: so every page a case reaches is a
+// page of the build, and nothing comes from the network. Run build.bat first.
+// Each case is a line and column in helphost/Sources/Cases.twin, so a change to
+// that file is a change to this table.
+//
+// Run it with addin-test.bat, which gives it a lane; on its own it is skipped.
+
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { before, test } from "node:test";
+import { fileURLToPath } from "node:url";
+import { createStaticHandler } from "../../builder/static-files.mjs";
+import { sleep } from "../../scripts/lib/tb-ide.mjs";
+import { loadedAddins } from "../../scripts/lib/tb-ide-addins.mjs";
+import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs";
+import {
+ click,
+ listViewItems,
+ notifications,
+ openedUrls,
+ openFile,
+ pressKey,
+ select,
+ setCursor,
+ toolWindow,
+ typeText,
+ waitFor,
+} from "../../scripts/lib/tb-operate.mjs";
+import { frameEval, frameOf, serveLoopback } from "./pages.mjs";
+import { scenario } from "./scenario.mjs";
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(HERE, "..", "..");
+const ADDIN = path.join(ROOT, "add-in");
+const INDEX = path.join(ADDIN, "Resources", "SYMBOLS", "symbols.json");
+const BUILT = path.join(ROOT, "docs", "_site");
+const HOST = path.join(HERE, "helphost");
+const FILE = "/HelpHost/Sources/Cases.twin";
+const PANE = "tbDocsHelpPane";
+
+const addinLines = async (c, mark) => linesSince(c, mark, { prefix: "[tbDocsHelp] " });
+
+// An expression on the pane's shadow root, `root`.
+const inPane = (c, body) =>
+ c.evaluate(`(() => {
+ const w = toolWindowsById[${JSON.stringify(PANE)}];
+ if (!w) return null;
+ const root = w.bodyElement.getRootNode();
+ ${body}
+})()`);
+
+const frameSrc = (c) => inPane(c, `return root.querySelector("#helpPage")?.getAttribute("src") ?? null;`);
+// "dark" or "light", from the IDE theme's panel colour rather than from the
+// API the add-in asks (Themes.ActiveThemeNameGroup).
+const themeGroup = (c) =>
+ c.evaluate(`(() => {
+ const probe = document.createElement("div");
+ probe.style.background = "var(--themeGeneralPanelBackColor)";
+ document.body.appendChild(probe);
+ const [r, g, b] = getComputedStyle(probe).backgroundColor.match(/\\d+/g).map(Number);
+ probe.remove();
+ return 0.299 * r + 0.587 * g + 0.114 * b < 128 ? "dark" : "light";
+})()`);
+const focusedId = (c) => inPane(c, "return root.activeElement?.id ?? null;");
+const searchValue = (c) => inPane(c, `return root.querySelector("#helpSearch")?.value ?? null;`);
+const results = async (c) => (await listViewItems(c, { toolWindow: PANE, css: "#helpResults" })).map((i) => i.text);
+// What shows in place of the page for a name with none: null while the page shows.
+const summary = (c) =>
+ inPane(
+ c,
+ `const s = root.querySelector("#helpSummary");
+ if (!s || s.style.display === "none") return null;
+ const text = (css) => s.querySelector(css)?.textContent ?? null;
+ return {
+ declaration: text(".declaration"),
+ description: text(".description"),
+ where: text(".where"),
+ frameHidden: root.querySelector("#helpPage").style.display === "none",
+ browserDisabled: root.querySelector("#helpBrowser").disabled,
+ };`,
+ );
+// Whether the frame shows, rather than a summary, with Open in browser enabled.
+const pageState = (c) =>
+ inPane(
+ c,
+ `return {
+ summary: root.querySelector("#helpSummary")?.style.display ?? null,
+ frame: root.querySelector("#helpPage")?.style.display ?? null,
+ browserDisabled: root.querySelector("#helpBrowser")?.disabled ?? null,
+ };`,
+ );
+
+async function at(c, line, column) {
+ await setCursor(c, line, column);
+ await sleep(100);
+}
+
+async function emptySearch(c) {
+ await click(c, { toolWindow: PANE, css: "#helpSearch" });
+ await pressKey(c, "End");
+ for (let n = (await searchValue(c)).length; n > 0; n--) await pressKey(c, "Backspace");
+}
+
+scenario("the help add-in: F1 and the help pane", (lane) => {
+ let c;
+ let entries;
+ let origin;
+ let server;
+ const served = []; // { path, status } for every request the site answered
+
+ // The frame shows `url` from the built site in the IDE's theme: the add-in
+ // gave it that src with ?theme= and ?pane=1, the page it loaded is the
+ // build's, not a 404, and the page applied the theme and hid the site's
+ // navigation and header. With `fresh`, the page is a new document: not the
+ // one markFrame marked.
+ async function showsPage(url, { fresh = false } = {}) {
+ const group = await themeGroup(c);
+ const [pagePath, fragment] = url.split("#");
+ const want = `${origin}${pagePath}?theme=${group}&pane=1${fragment ? `#${fragment}` : ""}`;
+ // The frame can have this src already, hidden behind a summary, so the
+ // page showing again is waited for too.
+ const shown = { summary: "none", frame: "", browserDisabled: false };
+ const src = await waitFor(c, async (c) => {
+ const s = await frameSrc(c);
+ return s === want && JSON.stringify(await pageState(c)) === JSON.stringify(shown) && s;
+ });
+ assert.equal(src ?? (await frameSrc(c)), want, "the frame's src");
+ assert.ok((await toolWindow(c, PANE))?.visible, "the pane is not showing");
+ assert.deepEqual(await pageState(c), shown);
+ const loaded = await waitFor(c, async (c) => {
+ const f = await frameOf(c, origin);
+ const loadedUrl = `${origin}${pagePath.replace(/\/$/, "")}?theme=${group}&pane=1`;
+ if (!f || f.url.replace(/\/\?/, "?") !== loadedUrl) return false;
+ // A src set to the URL the frame already has reloads it, and a question
+ // put to the page being replaced fails: that is not loaded yet.
+ const ready = `document.readyState === "complete"${fresh ? " && !window.tbDocsTestMark" : ""}`;
+ return (await frameEval(c, origin, ready).catch(() => false)) === true;
+ });
+ assert.ok(loaded, `the frame did not load ${want}: ${JSON.stringify(await frameOf(c, origin))}`);
+ if (fragment) assert.equal((await frameOf(c, origin)).urlFragment, `#${fragment}`);
+ const page = served.filter((r) => r.path.replace(/\/$/, "") === pagePath.replace(/\/$/, "")).at(-1);
+ assert.equal(page?.status, 200, `${url} as served: ${JSON.stringify(page)}`);
+ assert.equal(
+ await frameEval(c, origin, `document.documentElement.getAttribute("data-theme")`),
+ group,
+ "the page's theme",
+ );
+ assert.deepEqual(await paneChrome(), PANE_CHROME, "the site's chrome in the pane");
+ }
+
+ // What the frame's page shows of the site's chrome: the pane hides the
+ // sidebar and the header, and keeps the breadcrumbs and the footer.
+ const PANE_CHROME = { sidebar: "none", header: "none", breadcrumbs: "block", footer: "block" };
+ const paneChrome = () =>
+ frameEval(
+ c,
+ origin,
+ `(() => {
+ const shown = (s) => { const e = document.querySelector(s); return e ? getComputedStyle(e).display : "missing"; };
+ return { sidebar: shown(".side-bar"), header: shown(".main-header"), breadcrumbs: shown(".breadcrumb-nav"), footer: shown("footer[role=contentinfo]") };
+ })()`,
+ );
+
+ // Marks the page the frame shows now, so that showsPage can tell a page F1
+ // loaded from it: F1 on the name whose page shows already loads it again,
+ // and the page before the reload looks loaded until the reload begins.
+ const markFrame = () => frameEval(c, origin, "window.tbDocsTestMark = true").catch(() => {});
+
+ // Press F1 at line:column, and expect `want` in place of the page, the frame
+ // hidden, Open in browser disabled, and nothing printed.
+ async function f1Summary(line, column, want) {
+ await at(c, line, column);
+ const mark = await consoleMark(c);
+ await pressKey(c, "F1");
+ const got = await waitFor(c, async (c) => {
+ const s = await summary(c);
+ return s?.declaration === want.declaration && s;
+ });
+ assert.deepEqual(got || (await summary(c)), { ...want, frameHidden: true, browserDisabled: true });
+ assert.ok((await toolWindow(c, PANE))?.visible, "the pane is not showing");
+ assert.deepEqual(await addinLines(c, mark), [], "the add-in printed something");
+ }
+
+ // Press F1 (or run `act`), and expect `url` in the pane and nothing printed.
+ async function f1Shows(url, act = () => pressKey(c, "F1")) {
+ const mark = await consoleMark(c);
+ await markFrame();
+ await act();
+ await showsPage(url, { fresh: true });
+ assert.deepEqual(await addinLines(c, mark), [], "the add-in printed something");
+ }
+
+ before(async () => {
+ assert.ok(fs.existsSync(path.join(BUILT, "tB", "symbols.json")), `no built site in ${BUILT}: run build.bat`);
+ entries = JSON.parse(fs.readFileSync(INDEX, "utf8")).symbols.length;
+ const files = createStaticHandler(BUILT);
+ server = await serveLoopback((req, res) => {
+ res.on("finish", () => served.push({ path: req.url.split(/[?#]/)[0], status: res.statusCode }));
+ return files(req, res);
+ });
+ origin = `http://localhost:${server.port}`;
+ await lane.addAddin(ADDIN);
+ c = await lane.open(HOST, { env: { TB_DOCS_HELP_SITE: origin } });
+ await openFile(c, FILE, { line: 5, column: 9 });
+ });
+
+ test("it loads with the whole index and the test switch on, and makes its pane", async () => {
+ assert.ok((await loadedAddins(c)).some((a) => a.name === "twinBASIC Documentation Help"));
+ const loaded = await waitFor(c, async (c) => (await addinLines(c, null)).find((l) => l.startsWith("loaded")));
+ // Checked before any key is pressed: with the switch off, the add-in would
+ // ignore TB_DOCS_HELP_SITE, and "Open in browser" would start a browser.
+ assert.equal(loaded, `loaded, index ${entries} entries, test switch on`);
+ const pane = await toolWindow(c, PANE);
+ assert.equal(pane?.title, "TWINBASIC HELP");
+ assert.equal(pane.visible, false);
+ assert.equal(await frameSrc(c), null, "a page in the frame before any was asked for");
+ });
+
+ // [line, column, URL] in Cases.twin; the comment says what the cursor is on.
+ // The compiler's hover says where most of these are declared; the ones it
+ // says nothing about (Debug.Print, a statement, a comment) take the index's
+ // lookup alone.
+ const CASES = [
+ [5, 9, "/tB/Modules/Interaction/MsgBox", "the first letter of a name"],
+ [5, 15, "/tB/Modules/Interaction/MsgBox", "just after a name"],
+ [6, 27, "/tB/Modules/Interaction/MsgBox", "VBA.Interaction.MsgBox: Container.Name"],
+ [6, 15, "/tB/Modules/Interaction/", "VBA.Interaction: Package.Name"],
+ [7, 15, "/tB/Modules/Debug#print", "Debug.Print: the member, not the statement"],
+ [7, 21, "/tB/Modules/Strings/Len", "a function inside a call"],
+ [8, 9, "/tB/Core/Dim", "a statement"],
+ [9, 9, "/tB/Core/Close", "a bare name: the statement wins over twelve methods"],
+ [10, 28, "/tB/Packages/tbIDE/ToolWindows#add", "a chain in a comment: ToolWindows.Add"],
+ [4, 12, "/tB/Modules/Collection/Add", "c.Add, c a Collection: VBA._Collection's"],
+ [21, 15, "/tB/Modules/Collection/Add", ".Add inside With on a Collection"],
+ [16, 35, "/tB/Packages/tbIDE/ToolWindows#add", "Host.ToolWindows.Add: tbIDE.IToolWindowsV1's"],
+ [14, 25, "/tB/Packages/VB/CheckBox/#value", "a CheckBox's Value: VB._CheckBox's"],
+ [14, 42, "/tB/Modules/Constants/VbMsgBoxStyle", "an enumeration's value"],
+ [14, 52, "/tB/Modules/Constants/", "a constant"],
+ // Hover names another procedure for these, GetAppPath and GetErrNumber,
+ // but its package still picks VB's App from AppGlobalClassObject's, and
+ // ErrObject.Number from every other Number.
+ [14, 36, "/tB/Packages/VB/App/#path", "App.Path"],
+ [14, 64, "/tB/Modules/ErrObject/Number", "Err.Number"],
+ ];
+ for (const [line, column, url, what] of CASES) {
+ test(`F1 on ${what} (${line}:${column})`, async () => {
+ await at(c, line, column);
+ await f1Shows(url);
+ });
+ }
+
+ test("a selection is looked up as selected", async () => {
+ // "Print" alone is the bare name, where the cursor would give Debug.Print.
+ await select(c, { startLine: 7, startColumn: 15, endLine: 7, endColumn: 20 });
+ await sleep(100);
+ await f1Shows("/tB/Core/Print");
+ });
+
+ test("a name with no page says so and leaves the page", async () => {
+ const was = await frameSrc(c);
+ await at(c, 8, 13);
+ await pressKey(c, "F1");
+ const text = "No help for 'zzqHelpProbe'";
+ const shown = await waitFor(c, async (c) => (await notifications(c)).find((t) => t.trim() === text));
+ assert.ok(shown, `notifications: ${JSON.stringify(await notifications(c))}`);
+ await sleep(300);
+ assert.equal(await frameSrc(c), was);
+ });
+
+ test("F1 with no name at the cursor puts the focus in the search box", async () => {
+ await at(c, 3, 3);
+ await pressKey(c, "F1");
+ assert.equal(await waitFor(c, async (c) => (await focusedId(c)) === "helpSearch" && "helpSearch"), "helpSearch");
+ });
+
+ // P15: the compiler's hover gives a name's [Description]. A name declared
+ // where the documentation has no page shows that in place of the page.
+ test("a name the project declares shows its declaration and description, though a package has a page for it", async () => {
+ // Cases.Beep, not VBA.Interaction.Beep.
+ await f1Summary(18, 10, {
+ declaration: "Sub Beep ( )",
+ description: "Sounds the project's own tone.",
+ where: "Declared in HelpHost.Cases",
+ });
+ });
+
+ test("a procedure of the project with no [Description] shows its declaration", async () => {
+ await f1Summary(36, 22, {
+ declaration: "Function Plain ( ) As Long",
+ description: "No description.",
+ where: "Declared in HelpHost.Cases",
+ });
+ });
+
+ test("a class of the project shows its description, without its members", async () => {
+ await f1Summary(35, 24, {
+ declaration: "class Gadget",
+ description: "A gadget of the project's own.",
+ where: "Declared in HelpHost",
+ });
+ });
+
+ test("F1 on a procedure's own name where it is declared says so, and leaves the pane", async () => {
+ const was = await summary(c);
+ await at(c, 27, 17);
+ await pressKey(c, "F1");
+ const text = "No help for 'Beep': it is declared here";
+ const shown = await waitFor(c, async (c) => (await notifications(c)).find((t) => t.trim() === text));
+ assert.ok(shown, `notifications: ${JSON.stringify(await notifications(c))}`);
+ await sleep(300);
+ assert.deepEqual(await summary(c), was);
+ });
+
+ test("a member of a late-bound object lists its pages, and a click shows one", async () => {
+ // o.Add, o As Object: the compiler cannot tell either, so every page of an
+ // Add is listed.
+ await at(c, 17, 12);
+ const was = await frameSrc(c);
+ await pressKey(c, "F1");
+ const items = await waitFor(c, async (c) => {
+ const r = await results(c);
+ return r.length > 0 && r;
+ });
+ assert.equal(items?.length, 12, JSON.stringify(items));
+ assert.ok(items.includes("Collection.Addmethod") && items.includes("ToolWindows.Addmethod"), JSON.stringify(items));
+ assert.equal(await searchValue(c), "o.Add");
+ assert.equal(await frameSrc(c), was, "a list must not change the page");
+ await f1Shows("/tB/Modules/Collection/Add", () =>
+ click(c, { toolWindow: PANE, css: "#helpResults .hit .label", text: "Collection.Add" }),
+ );
+ });
+
+ test("the search box finds names as typed, and Enter shows the first", async () => {
+ await emptySearch(c);
+ await typeText(c, "msgbo");
+ const items = await waitFor(c, async (c) => {
+ const r = await results(c);
+ return r[0] === "Interaction.MsgBoxfunction" && r;
+ });
+ assert.ok(items, JSON.stringify(await results(c)));
+ await f1Shows("/tB/Modules/Interaction/MsgBox", () => pressKey(c, "Enter"));
+ });
+
+ test("a search that matches nothing says so", async () => {
+ await emptySearch(c);
+ await typeText(c, "zzqq");
+ assert.ok(
+ await waitFor(c, async (c) => (await results(c)).join() === "No matches"),
+ JSON.stringify(await results(c)),
+ );
+ });
+
+ test("the toolbar's Help button puts the focus in the search box", async () => {
+ await at(c, 7, 21);
+ await click(c, "addinButton-tbDocsHelp");
+ assert.equal(await waitFor(c, async (c) => (await focusedId(c)) === "helpSearch" && "helpSearch"), "helpSearch");
+ });
+
+ test("Open in browser opens the page the pane shows", async () => {
+ const mark = await consoleMark(c);
+ await click(c, { toolWindow: PANE, css: "#helpBrowser" });
+ const urls = await waitFor(c, async (c) => {
+ const u = await openedUrls(c, { since: mark });
+ return u.length > 0 && u;
+ });
+ assert.deepEqual(urls, [`${origin}/tB/Modules/Interaction/MsgBox`]);
+ });
+
+ // The background, since it is not inherited: the IDE gives the tool window
+ // the theme's text colour already, so the pane's would match with no CSS.
+ test("the pane's colours are the IDE theme's", async () => {
+ const colours = await inPane(
+ c,
+ `const probe = document.createElement("div");
+ probe.style.background = "var(--themeGeneralPanelBackColor)";
+ document.body.appendChild(probe);
+ const theme = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return { theme, pane: getComputedStyle(root.querySelector("#helpWrap")).backgroundColor };`,
+ );
+ assert.ok(colours.theme && colours.theme !== "rgba(0, 0, 0, 0)", JSON.stringify(colours));
+ assert.equal(colours.pane, colours.theme);
+ });
+
+ // Through the IDE's own commands for its theme menu, which tell the compiler,
+ // and so the add-in, of the change (OnChangedTheme). The IDE saves the theme
+ // where the user's own IDE reads it; the run's registry tidy puts it back
+ // (restoreTheme in scripts/lib/tb-registry.mjs).
+ test("a theme change loads the page again in the new theme", async () => {
+ const page = "/tB/Modules/Interaction/MsgBox";
+ const was = await themeGroup(c);
+ const other = was === "dark" ? "light" : "dark";
+ const switchTo = (group) =>
+ c.evaluate(
+ `executeIdeCommand(${JSON.stringify(group === "dark" ? "tbTheme_SwitchToDarkMode" : "tbTheme_SwitchToLightMode")})`,
+ );
+ try {
+ await switchTo(other);
+ assert.equal(await themeGroup(c), other, "the IDE's theme did not change");
+ await showsPage(page);
+ } finally {
+ await switchTo(was);
+ }
+ await showsPage(page);
+ });
+
+ test("a compiler restart empties the pane, and the add-in puts its page back", async () => {
+ // The old frame is marked, so that the frame found after is the new pane's.
+ await inPane(c, `root.querySelector("#helpPage").dataset.beforeRestart = "1";`);
+ const mark = await consoleMark(c);
+ await lane.restartCompiler();
+ assert.ok(await waitFor(c, async (c) => (await addinLines(c, mark)).some((l) => l.startsWith("loaded"))));
+ const src = await waitFor(c, (c) =>
+ inPane(
+ c,
+ `const f = root.querySelector("#helpPage"); return f && !f.dataset.beforeRestart && f.getAttribute("src");`,
+ ),
+ );
+ assert.equal(src, `${origin}/tB/Modules/Interaction/MsgBox?theme=${await themeGroup(c)}&pane=1`);
+ });
+
+ // A page reached from the pane's page has no parameters of its own; the site
+ // keeps both for the tab.
+ test("a page reached by a link keeps the IDE's theme and the pane's layout", async () => {
+ await f1Shows("/tB/Modules/Interaction/MsgBox", () => at(c, 5, 9).then(() => pressKey(c, "F1")));
+ const next = "/tB/Modules/Strings/Len";
+ await frameEval(c, origin, `location.href = ${JSON.stringify(next)}`);
+ const loaded = await waitFor(c, async (c) => {
+ const f = await frameOf(c, origin);
+ if (f?.url !== `${origin}${next}`) return false;
+ return (await frameEval(c, origin, `document.readyState === "complete"`).catch(() => false)) === true;
+ });
+ assert.ok(loaded, `the frame did not load ${next}: ${JSON.stringify(await frameOf(c, origin))}`);
+ assert.equal(
+ await frameEval(c, origin, `document.documentElement.getAttribute("data-theme")`),
+ await themeGroup(c),
+ "the page's theme",
+ );
+ assert.deepEqual(await paneChrome(), PANE_CHROME, "the site's chrome in the pane");
+ });
+
+ return () => server?.close();
+});
diff --git a/test/addin/helphost/Settings b/test/addin/helphost/Settings
new file mode 100644
index 000000000..6d22e9c15
--- /dev/null
+++ b/test/addin/helphost/Settings
@@ -0,0 +1,61 @@
+{
+ "configuration.inherits": "Defaults",
+ "project.appTitle": "HelpHost",
+ "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}",
+ "project.buildType": "Standard EXE",
+ "project.description": "The project test/addin/help.test.mjs opens to press F1 in. Its lines are the scenario's cases, by line and column: change one and the scenario's table with it. scripts/lib/tb-lane.mjs stages a copy, with project.id and project.buildPath rewritten for the lane.",
+ "project.exportPathIsV2": true,
+ "project.id": "{D4EF6D85-EF9A-4CCB-8F0D-977A73EF5994}",
+ "project.name": "HelpHost",
+ "project.optionExplicit": true,
+ "project.references": [
+ {
+ "id": "{00020430-0000-0000-C000-000000000046}",
+ "lcid": 0,
+ "name": "OLE Automation",
+ "path32": "C:\\Windows\\SysWOW64\\stdole2.tlb",
+ "path64": "C:\\Windows\\System32\\stdole2.tlb",
+ "symbolId": "stdole",
+ "versionMajor": 2,
+ "versionMinor": 0
+ },
+ {
+ "id": "{99DEC38C-75F6-4488-8EE7-2D52D83881D2}",
+ "isCompilerPackage": true,
+ "licence": "MIT",
+ "name": "[COMPILER PACKAGE] twinBASIC IDE Extensibility Package",
+ "path32": "",
+ "path64": "",
+ "publisher": "TWINBASIC-COMPILER",
+ "symbolId": "tbIDE",
+ "versionBuild": 0,
+ "versionMajor": 0,
+ "versionMinor": 0,
+ "versionRevision": 1
+ },
+ {
+ "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}",
+ "isCompilerPackage": true,
+ "licence": "MIT",
+ "name": "[COMPILER PACKAGE] twinBASIC - VB Compatibility Package (Forms)",
+ "path32": "",
+ "path64": "",
+ "publisher": "TWINBASIC-COMPILER",
+ "symbolId": "VB",
+ "versionBuild": 0,
+ "versionMajor": 0,
+ "versionMinor": 0,
+ "versionRevision": 31
+ }
+ ],
+ "project.settingsVersion": 1,
+ "project.startupObject": "Sub Main",
+ "project.warnings": {
+ "errors": [],
+ "hints": [],
+ "ignored": [],
+ "info": [],
+ "warnings": []
+ },
+ "runtime.useUnicodeStandardLibrary": true
+}
diff --git a/test/addin/helphost/Sources/Cases.twin b/test/addin/helphost/Sources/Cases.twin
new file mode 100644
index 000000000..23d1640dc
--- /dev/null
+++ b/test/addin/helphost/Sources/Cases.twin
@@ -0,0 +1,38 @@
+Module Cases
+ Public Sub Main()
+ Dim c As New Collection
+ c.Add 1
+ MsgBox "x"
+ VBA.Interaction.MsgBox "y"
+ Debug.Print Len("abc")
+ Dim zzqHelpProbe As Long
+ Close #1
+ ' Host.ToolWindows.Add(name, id)
+ End Sub
+
+ Public Sub Members(ByVal cb As CheckBox, ByVal Host As Host, ByVal o As Object)
+ Debug.Print cb.Value, App.Path, vbOKOnly, vbCrLf, Err.Number
+ Dim w As ToolWindow
+ Set w = Host.ToolWindows.Add("a", "b")
+ o.Add 1
+ Beep
+ Dim c2 As New Collection
+ With c2
+ .Add 2
+ End With
+ End Sub
+
+ ' The project's own Beep, in place of VBA's.
+ [Description("Sounds the project's own tone.")]
+ Public Sub Beep()
+ End Sub
+
+ Public Function Plain() As Long
+ Return 1
+ End Function
+
+ Public Sub Own()
+ Dim g As New Gadget
+ Debug.Print Plain(), g Is Nothing
+ End Sub
+End Module
diff --git a/test/addin/helphost/Sources/Gadget.twin b/test/addin/helphost/Sources/Gadget.twin
new file mode 100644
index 000000000..7a01e88b5
--- /dev/null
+++ b/test/addin/helphost/Sources/Gadget.twin
@@ -0,0 +1,7 @@
+' A class of the project's own, which the compiler's hover places "in library
+' HelpHost": F1 on it shows its description, not a page.
+[Description("A gadget of the project's own.")]
+Class Gadget
+ Public Sub Spin()
+ End Sub
+End Class
diff --git a/test/addin/lanes.mjs b/test/addin/lanes.mjs
index 982f11dbf..45b3a8c5f 100644
--- a/test/addin/lanes.mjs
+++ b/test/addin/lanes.mjs
@@ -33,4 +33,7 @@ export default [
{ file: "reload.test.mjs" },
{ file: "entry.test.mjs" },
{ file: "env.test.mjs" },
+ // The help add-in, add-in/ at the repository's root. It saves the page its
+ // pane shows.
+ { file: "help.test.mjs", settings: ["tbDocsHelp"] },
];
diff --git a/test/addin/pages.mjs b/test/addin/pages.mjs
new file mode 100644
index 000000000..b9c5205d9
--- /dev/null
+++ b/test/addin/pages.mjs
@@ -0,0 +1,69 @@
+// Pages for a tool window's frame: a server on localhost for them, and the
+// frame read back through the IDE page's DevTools connection. Used by
+// panes.test.mjs and help.test.mjs.
+
+import http from "node:http";
+
+/**
+ * Serves `handler` on one port of both loopback addresses, since the frame may
+ * resolve localhost to either. A port whose IPv6 side something else holds is
+ * given up for another, so that no request can reach a stranger's server.
+ *
+ * @param {http.RequestListener} handler
+ * @returns {Promise<{ port: number, close: () => void }>}
+ */
+export async function serveLoopback(handler) {
+ const listen = (host, port) =>
+ new Promise((resolve, reject) => {
+ const s = http.createServer(handler);
+ s.once("error", reject);
+ s.listen(port, host, () => resolve(s));
+ });
+ for (let tries = 0; tries < 5; tries++) {
+ const v4 = await listen("127.0.0.1", 0);
+ const port = v4.address().port;
+ try {
+ const v6 = await listen("::1", port);
+ return {
+ port,
+ close: () => {
+ v4.close();
+ v6.close();
+ },
+ };
+ } catch (e) {
+ if (e.code === "EADDRNOTAVAIL" || e.code === "EAFNOSUPPORT") {
+ return { port, close: () => v4.close() }; // no IPv6 loopback at all
+ }
+ v4.close();
+ }
+ }
+ throw new Error("no loopback port was free on both IPv4 and IPv6");
+}
+
+/**
+ * The IDE page's child frame whose URL starts with `origin`, as
+ * Page.getFrameTree gives it ({ id, url, urlFragment, ... }), or null. A frame
+ * on another site is not in the tree: it has a DevTools target of its own.
+ */
+export async function frameOf(c, origin) {
+ const tree = await c.send("Page.getFrameTree");
+ return (tree.frameTree.childFrames ?? []).map((f) => f.frame).find((f) => f.url.startsWith(origin)) ?? null;
+}
+
+/**
+ * `expression` evaluated in that frame's document, in an isolated world of the
+ * test's own so that nothing of the page's script is touched.
+ */
+export async function frameEval(c, origin, expression) {
+ const frame = await frameOf(c, origin);
+ if (!frame) throw new Error(`no frame on ${origin}`);
+ const { executionContextId } = await c.send("Page.createIsolatedWorld", {
+ frameId: frame.id,
+ worldName: "addin-test",
+ });
+ const r = await c.send("Runtime.evaluate", { expression, contextId: executionContextId, returnByValue: true });
+ if (r.exceptionDetails)
+ throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
+ return r.result.value;
+}
diff --git a/test/addin/panes.test.mjs b/test/addin/panes.test.mjs
index abe3c280e..77807bbad 100644
--- a/test/addin/panes.test.mjs
+++ b/test/addin/panes.test.mjs
@@ -20,7 +20,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
-import http from "node:http";
import path from "node:path";
import { before, test } from "node:test";
import { fileURLToPath } from "node:url";
@@ -28,6 +27,7 @@ import { sleep } from "../../scripts/lib/tb-ide.mjs";
import { loadedAddins } from "../../scripts/lib/tb-ide-addins.mjs";
import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs";
import { click, clickAt, pressKey, toolWindow, waitFor } from "../../scripts/lib/tb-operate.mjs";
+import { frameEval, frameOf, serveLoopback } from "./pages.mjs";
import { scenario } from "./scenario.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -51,45 +51,16 @@ const PAGES = {
"/c.html": page("Fixture C", "page C
"),
};
-// Serve PAGES on one port of both loopback addresses, since the frame may
-// resolve localhost to either, recording every request. A port whose IPv6
-// side something else holds is given up for another, so that no request can
-// reach a stranger's server.
+// Serve PAGES on localhost, recording every request.
async function servePages() {
const requests = [];
- const handler = (req, res) => {
+ const server = await serveLoopback((req, res) => {
requests.push({ url: req.url, dest: req.headers["sec-fetch-dest"] });
const body = PAGES[req.url.split("?")[0]];
res.writeHead(body ? 200 : 404, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
res.end(body ?? "");
- };
- const listen = (host, port) =>
- new Promise((resolve, reject) => {
- const s = http.createServer(handler);
- s.once("error", reject);
- s.listen(port, host, () => resolve(s));
- });
- for (let tries = 0; tries < 5; tries++) {
- const v4 = await listen("127.0.0.1", 0);
- const port = v4.address().port;
- try {
- const v6 = await listen("::1", port);
- return {
- port,
- requests,
- close: () => {
- v4.close();
- v6.close();
- },
- };
- } catch (e) {
- if (e.code === "EADDRNOTAVAIL" || e.code === "EAFNOSUPPORT") {
- return { port, requests, close: () => v4.close() }; // no IPv6 loopback at all
- }
- v4.close();
- }
- }
- throw new Error("no loopback port was free on both IPv4 and IPv6");
+ });
+ return { ...server, requests };
}
// ------------------------------------------------------------------ reading
@@ -97,25 +68,6 @@ async function servePages() {
// The probe's lines in the DEBUG CONSOLE since a mark, without the prefix.
const probeLines = (c, mark) => linesSince(c, mark, { prefix: "[PanesProbe] " });
-// The frame, and an expression evaluated in its document, in an isolated world
-// of the test's own so that nothing of the page's script is touched.
-async function frameOf(c, origin) {
- const tree = await c.send("Page.getFrameTree");
- return (tree.frameTree.childFrames ?? []).map((f) => f.frame).find((f) => f.url.startsWith(origin)) ?? null;
-}
-async function frameEval(c, origin, expression) {
- const frame = await frameOf(c, origin);
- if (!frame) throw new Error(`no frame on ${origin}`);
- const { executionContextId } = await c.send("Page.createIsolatedWorld", {
- frameId: frame.id,
- worldName: "panes-test",
- });
- const r = await c.send("Runtime.evaluate", { expression, contextId: executionContextId, returnByValue: true });
- if (r.exceptionDetails)
- throw new Error(r.exceptionDetails.exception?.description ?? JSON.stringify(r.exceptionDetails));
- return r.result.value;
-}
-
// An element of the tool window, as a rectangle in the page's coordinates.
const rectOf = (c, css) =>
c.evaluate(`(() => {
diff --git a/test/addin/probes/symbols/Settings b/test/addin/probes/symbols/Settings
index 1f1105ce0..d09f381ec 100644
--- a/test/addin/probes/symbols/Settings
+++ b/test/addin/probes/symbols/Settings
@@ -18,6 +18,15 @@
"versionMajor": 2,
"versionMinor": 0
},
+ {
+ "id": "{420B2830-E718-11CF-893D-00A0C9054228}",
+ "lcid": 0,
+ "name": "Microsoft Scripting Runtime",
+ "path32": "C:\\Windows\\SysWOW64\\scrrun.dll",
+ "path64": "C:\\Windows\\System32\\scrrun.dll",
+ "versionMajor": 1,
+ "versionMinor": 0
+ },
{
"id": "{99DEC38C-75F6-4488-8EE7-2D52D83881D2}",
"isCompilerPackage": true,
diff --git a/test/addin/probes/symbols/Sources/Described.twin b/test/addin/probes/symbols/Sources/Described.twin
new file mode 100644
index 000000000..cd7aaff50
--- /dev/null
+++ b/test/addin/probes/symbols/Sources/Described.twin
@@ -0,0 +1,51 @@
+' P15 in WIP.HelpAddin.md: what hover gives for names that carry a
+' [Description], for one that does not, and for members of a COM type library
+' with help strings (Scripting). symbols.test.mjs asks about each name where
+' UseThem uses it. Nothing builds or runs it.
+[Description("A module that holds the probe's procedures.")]
+Module Described
+ [Description("The number of widgets.")]
+ Public Const WidgetCount As Long = 3
+
+ [Description("The module's counter.")]
+ Public Counter As Long
+
+ [Description("Adds two numbers and returns the sum.")]
+ Public Function AddTwo(ByVal a As Long, ByVal b As Long) As Long
+ Return a + b
+ End Function
+
+ Public Function NoDescription() As Long
+ Return 1
+ End Function
+
+ [Description("A colour.")]
+ Public Enum Shade
+ [Description("The light one.")]
+ Light = 1
+ Dark = 2
+ End Enum
+
+ [Description("A point.")]
+ Public Type Point
+ X As Long
+ Y As Long
+ End Type
+
+ Public Sub UseThem()
+ Dim p As Point
+ Dim w As New Widget
+ Debug.Print AddTwo(1, 2), WidgetCount, Counter, NoDescription()
+ Debug.Print Shade.Light, p.X
+ w.Resize 3
+ Dim d As New Scripting.Dictionary
+ d.Add "k", 1
+ End Sub
+End Module
+
+[Description("A widget class.")]
+Class Widget
+ [Description("Changes the widget's size.")]
+ Public Sub Resize(ByVal n As Long)
+ End Sub
+End Class
diff --git a/test/addin/probes/symbols/Sources/Symbols.twin b/test/addin/probes/symbols/Sources/Symbols.twin
index c0ac88a3a..bd9678b06 100644
--- a/test/addin/probes/symbols/Sources/Symbols.twin
+++ b/test/addin/probes/symbols/Sources/Symbols.twin
@@ -29,4 +29,15 @@ Module Symbols
Dim d As Host
Debug.Print h Is Nothing, count, col Is Nothing, o Is Nothing, IsEmpty(v), r Is Nothing, s, d Is Nothing
End Sub
+
+ ' Names of other kinds, for the help add-in's increment 3.
+ Public Sub Probe3(ByVal pb As PropertyBag)
+ Debug.Print vbOKOnly, vbCrLf, Err.Number
+ Debug.Print Left$("abc", 1), pb.ReadProperty("x")
+ Dim t As VbMsgBoxStyle
+ Dim c2 As New Collection
+ With c2
+ .Add 2
+ End With
+ End Sub
End Module
diff --git a/test/addin/symbols.test.mjs b/test/addin/symbols.test.mjs
index 27632bf06..84e2485a5 100644
--- a/test/addin/symbols.test.mjs
+++ b/test/addin/symbols.test.mjs
@@ -55,10 +55,32 @@ const hover = async (c, text, word) =>
const definition = (c, text, word) =>
lsp(c, "textDocument/definition", { textDocument: doc, position: at(text, word) });
+// P15: the names of Described.twin, asked about without opening it, since hover
+// takes any file of the project.
+const DESCRIBED = readFileSync(path.join(PROJECT, "Sources", "Described.twin"), "utf8").split(/\r?\n/);
+function atDescribed(text, word) {
+ const line = DESCRIBED.findIndex((s) => s.includes(text));
+ assert.ok(line >= 0, `no line of Described.twin holds ${JSON.stringify(text)}`);
+ const re = new RegExp(`\\b${word}\\b`, "g");
+ re.lastIndex = DESCRIBED[line].indexOf(text);
+ const i = re.exec(DESCRIBED[line])?.index;
+ assert.ok(i !== undefined, `no ${word} in ${JSON.stringify(text)}`);
+ return { line, character: i + Math.floor(word.length / 2) };
+}
+const hoverDescribed = async (c, text, word) =>
+ (
+ await lsp(c, "textDocument/hover", {
+ textDocument: { uri: "twinbasic:/SymbolsProbe/Sources/Described.twin" },
+ position: atDescribed(text, word),
+ })
+ )?.contents.value ?? null;
+
// The heading a procedure's documentation starts with names where it is
// declared: "## **MsgBox** ... `in VBA.Interaction`".
-const declaredIn = (markdown) => /^## \*\*\w+\*\*[^`\r\n]*`in ([\w.]+)`/m.exec(markdown ?? "")?.[1] ?? null;
+const declaredIn = (markdown) => /^## \*\*\w+\$?\*\*[^`\r\n]*`in ([\w.]+)`/m.exec(markdown ?? "")?.[1] ?? null;
const firstLine = (markdown) => (markdown ?? "").split(/\r?\n/)[0];
+// The first "`in ...`" anywhere, whatever comes before it.
+const whereIn = (markdown) => /`in ([^`\r\n]+)`/.exec(markdown ?? "")?.[1] ?? null;
// Signature help for the call whose argument list starts just after `after`
// on the line holding `text`: the request the code editor makes when the
@@ -122,6 +144,38 @@ scenario("P5: what the compiler says about the name under the cursor", (lane) =>
assert.match(toolWindow, /\*\[default\]\* tbIDE\.IToolWindowV1\b/);
});
+ test("hover on an enumeration, its value and a constant names where each is declared in forms of their own", async () => {
+ // The add-in's Declared.ReadHover reads these forms.
+ const cases = [
+ ["Debug.Print vbOKOnly", "vbOKOnly", "*enum-value* **vbOKOnly** ", "VBA.Constants.VbMsgBoxStyle"],
+ ["Dim t As VbMsgBoxStyle", "VbMsgBoxStyle", "*enum* **VbMsgBoxStyle** ", "component VBA.Constants"],
+ ["vbCrLf, Err", "vbCrLf", "*constant* **vbCrLf As String** ", "VBA.Constants"],
+ ];
+ for (const [text, word, start, where] of cases) {
+ const h = await hover(c, text, word);
+ assert.ok(firstLine(h).startsWith(start), `${word}: ${JSON.stringify(h)}`);
+ assert.equal(whereIn(h), where, `${word}: ${JSON.stringify(h)}`);
+ }
+ });
+
+ test("hover can name the procedure behind a member, or an interface the class's default one inherits", async () => {
+ // Err.Number is VBA's hidden GetErrNumber (and, in the help lane's host,
+ // App.Path VB's GetAppPath); a PropertyBag's ReadProperty is on the
+ // interface PropertyBag_VB5, which the index's interfaces map lacks. A
+ // member inside With names its interface as c2.Add would.
+ const cases = [
+ ["Err.Number", "Number", "Function GetErrNumber (", "VBA._HiddenModule"],
+ ["pb.ReadProperty", "ReadProperty", "Function ReadProperty (", "VBRUN.PropertyBag_VB5"],
+ [".Add 2", "Add", "Sub Add (", "VBA._Collection"],
+ ['Left$("abc"', "Left", "Function Left$ (", "VBA.Strings"],
+ ];
+ for (const [text, word, kind, where] of cases) {
+ const h = await hover(c, text, word);
+ assert.ok(firstLine(h).startsWith(kind), `${word} in ${text}: ${JSON.stringify(h)}`);
+ assert.equal(declaredIn(h), where, `${word} in ${text}: ${JSON.stringify(h)}`);
+ }
+ });
+
test("hover on a variable gives its declaration, and on Debug.Print and a statement nothing", async () => {
assert.equal(await hover(c, "c.Add", "c"), "*local variable* Dim c As Collection");
assert.equal(await hover(c, "Nothing, count", "count"), "*parameter* ByVal count As Long");
@@ -228,4 +282,53 @@ scenario("P5: what the compiler says about the name under the cursor", (lane) =>
assert.equal(`twinbasic:${r.uri}`, d.uri);
assert.equal(r.line, d.range.start.line);
});
+
+ // P15. The help add-in shows this text for a name the documentation has no
+ // page for (ReadSummary in add-in/Sources/Declared.twin).
+ test("hover gives a name's [Description] after where it is declared, whatever kind of name it is", async () => {
+ const cases = [
+ ["Debug.Print AddTwo", "AddTwo", "SymbolsProbe.Described", "Adds two numbers and returns the sum."],
+ ["AddTwo(1, 2), WidgetCount", "WidgetCount", "SymbolsProbe.Described", "The number of widgets."],
+ ["WidgetCount, Counter", "Counter", "SymbolsProbe.Described", "The module's counter."],
+ ["Debug.Print Shade.Light", "Shade", "component SymbolsProbe.Described", "A colour."],
+ ["Dim p As Point", "Point", "component SymbolsProbe.Described", "A point."],
+ ["Dim w As New Widget", "Widget", "library SymbolsProbe", "A widget class."],
+ ["w.Resize", "Resize", "SymbolsProbe._Widget", "Changes the widget's size."],
+ ["Module Described", "Described", "library SymbolsProbe", "A module that holds the probe's procedures."],
+ ];
+ for (const [text, word, where, description] of cases) {
+ const h = await hoverDescribed(c, text, word);
+ assert.equal(whereIn(h), where, `${word} in ${text}: ${JSON.stringify(h)}`);
+ const after = h.slice(h.indexOf(`\`in ${where}\``));
+ assert.ok(after.includes(description), `${word} in ${text}: ${JSON.stringify(h)}`);
+ }
+ });
+
+ test("hover gives a type library's help string as a [Description]", async () => {
+ const h = await hoverDescribed(c, "d.Add", "Add");
+ assert.equal(declaredIn(h), "Scripting.IDictionary", JSON.stringify(h));
+ assert.match(h, /`in Scripting\.IDictionary`\s+Add a new key and item to the dictionary\.$/, JSON.stringify(h));
+ });
+
+ test("hover on a procedure with no [Description] gives the IDE's tip in its place", async () => {
+ const h = await hoverDescribed(c, "Counter, NoDescription", "NoDescription");
+ assert.equal(declaredIn(h), "SymbolsProbe.Described", JSON.stringify(h));
+ assert.match(h, /\n\*no further info available\. .*\[Description\(""\)\].*\*$/, JSON.stringify(h));
+ });
+
+ test("hover on a procedure's own name where it is declared gives code-generation details instead", async () => {
+ const h = await hoverDescribed(c, "Public Function AddTwo", "AddTwo");
+ assert.match(h, /TB-DEBUG CODEGEN SIZE/, JSON.stringify(h));
+ assert.equal(whereIn(h), null, JSON.stringify(h));
+ });
+
+ // The defect filed as twinbasic/twinbasic#2465 (bugs/filed/enum-member-description-hover/).
+ // When it is fixed this fails: make this test say what is right, and delete the folder.
+ test("a [Description] on an enumeration's member is listed as a member of the enumeration, and not shown on the member", async () => {
+ const shade = await hoverDescribed(c, "Debug.Print Shade.Light", "Shade");
+ assert.match(shade, /\n - Description\("The light one\."\)/, JSON.stringify(shade));
+ const light = await hoverDescribed(c, "Shade.Light, p.X", "Light");
+ assert.equal(whereIn(light), "SymbolsProbe.Described.Shade", JSON.stringify(light));
+ assert.ok(!light.includes("The light one."), JSON.stringify(light));
+ });
});
diff --git a/test/ide/export.test.mjs b/test/ide/export.test.mjs
index 9f2e226dd..5d8c0ae6f 100644
--- a/test/ide/export.test.mjs
+++ b/test/ide/export.test.mjs
@@ -11,12 +11,12 @@
// Run it with ide-test.bat, which gives it a lane; on its own it is skipped.
import assert from "node:assert/strict";
-import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import path from "node:path";
import { before, test } from "node:test";
import { fileURLToPath } from "node:url";
import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs";
+import { launchOnDesktop } from "../../scripts/lib/tb-ide.mjs";
import { compilerExe } from "../../scripts/lib/tb-install.mjs";
import { messageBoxes, waitFor } from "../../scripts/lib/tb-operate.mjs";
import { scenario } from "../addin/scenario.mjs";
@@ -106,14 +106,33 @@ scenario("Export Project", (lane) => {
assert.deepEqual(boxes, []);
});
- test("the command line's import refuses the IDE's export, for its compiler packages", () => {
+ // On a private desktop, as every program a test starts: a plain spawn of the
+ // compiler executable would show on the user's.
+ test("the command line's import refuses the IDE's export, for its compiler packages", async () => {
const out = path.join(root, "plain");
const packed = path.join(root, "repacked.twinproj");
- const r = spawnSync(compilerExe(lane.copy()), ["import", packed, `${out}\\`, "--overwrite"], {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
+ const run = await launchOnDesktop({
+ exe: compilerExe(lane.copy()),
+ args: ["import", packed, `${out}\\`, "--overwrite"],
+ desktop: `ide-test-cli-${lane.port}`,
+ env: process.env,
+ stdout: path.join(root, "import.out"),
+ stderr: path.join(root, "import.err"),
+ dialogs: "close",
});
- assert.equal(r.status, 999);
+ let timer;
+ const timedOut = await Promise.race([
+ run.exited.then(() => false),
+ new Promise((r) => {
+ timer = setTimeout(() => r(true), 60 * 1000);
+ }),
+ ]);
+ clearTimeout(timer);
+ if (timedOut) run.launcher.kill();
+ const { code, dialogs } = await run.finished;
+ assert.equal(timedOut, false, "the import ran past 60 s");
+ assert.deepEqual(dialogs, []);
+ assert.equal(code, 999);
assert.equal(existsSync(packed), false);
});
diff --git a/test/ide/packages.test.mjs b/test/ide/packages.test.mjs
index e35903396..e781e6718 100644
--- a/test/ide/packages.test.mjs
+++ b/test/ide/packages.test.mjs
@@ -21,7 +21,6 @@
// Run it with ide-test.bat, which gives it a lane; on its own it is skipped.
import assert from "node:assert/strict";
-import { execFileSync } from "node:child_process";
import { cpSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { before, test } from "node:test";
@@ -29,10 +28,10 @@ import { fileURLToPath } from "node:url";
import { waitForCompile } from "../../scripts/lib/tb-ide.mjs";
import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs";
import { click, clickAt, openFile, waitFor } from "../../scripts/lib/tb-operate.mjs";
+import { packTree } from "../../scripts/lib/tb-project.mjs";
import { scenario } from "../addin/scenario.mjs";
const HERE = path.dirname(fileURLToPath(import.meta.url));
-const REPO = path.resolve(HERE, "..", "..");
const HOST = path.join(HERE, "probes", "packages", "host");
const PKG = path.join(HERE, "probes", "packages", "DocProbePkg");
@@ -72,7 +71,7 @@ scenario("Import from file... in the Packages dialog", (lane) => {
before(async () => {
pack = path.join(lane.work, "DocProbePkg.twinpack");
- execFileSync(process.execPath, [path.join(REPO, "scripts", "impexp.mjs"), "import", pack, PKG], { stdio: "pipe" });
+ packTree(pack, PKG);
c = await lane.open(HOST);
});
@@ -173,7 +172,7 @@ scenario("Replacing an embedded package", (lane) => {
);
writeFileSync(source, readFileSync(source, "utf8").replace("Return 1", `Return ${version}`));
const out = `${tree}.twinpack`;
- execFileSync(process.execPath, [path.join(REPO, "scripts", "impexp.mjs"), "import", out, tree], { stdio: "pipe" });
+ packTree(out, tree);
return out;
}
diff --git a/test/ports.test.mjs b/test/ports.test.mjs
new file mode 100644
index 000000000..8ce460082
--- /dev/null
+++ b/test/ports.test.mjs
@@ -0,0 +1,112 @@
+// Unit tests for scripts/lib/tb-ports.mjs, which claims the DevTools ports a
+// run's IDEs use so that two runs never share one.
+//
+// The harness runs that claim ports need a twinBASIC install, so they run only
+// by hand; claiming a port needs none. Four child processes claim at once, from
+// a range where one port is listened on, one holds a stale lock and one a live
+// lock, and their claims are checked against the ports that were free.
+//
+// Runs with a bare `node --test test/ports.test.mjs`: no tree, no IDE.
+
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
+import net from "node:net";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { after, before, describe, test } from "node:test";
+import { pathToFileURL } from "node:url";
+import { claimPorts } from "../scripts/lib/tb-ports.mjs";
+
+const MODULE = pathToFileURL(path.join(import.meta.dirname, "../scripts/lib/tb-ports.mjs")).href;
+const LOCKS = path.join(tmpdir(), "tb-ports");
+const BASE = 19560;
+const CHILDREN = 4;
+const lockFile = (port) => path.join(LOCKS, `${port}.lock`);
+
+// A child claims three ports, prints them, and holds them until its standard
+// input closes, so that every child claims while the others hold theirs.
+const CHILD = `
+ const { claimPorts } = await import(${JSON.stringify(MODULE)});
+ console.log(JSON.stringify(await claimPorts(3, { from: ${BASE} })));
+ process.stdin.resume();
+ process.stdin.on("end", () => process.exit(0));
+`;
+
+// Starts a child; resolves to its ports once it has claimed them, and to
+// `release`, which ends it and resolves when it has exited.
+function claimInChild() {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ["--input-type=module", "-e", CHILD]);
+ const exited = new Promise((r) => child.on("close", r));
+ let out = "";
+ let err = "";
+ child.stdout.on("data", (d) => {
+ out += d;
+ if (out.includes("\n"))
+ resolve({
+ ports: JSON.parse(out),
+ release: () => {
+ child.stdin.end();
+ return exited;
+ },
+ });
+ });
+ child.stderr.on("data", (d) => {
+ err += d;
+ });
+ child.on("error", reject);
+ exited.then((code) => reject(new Error(`child exit ${code} before claiming: ${err}`)));
+ });
+}
+
+describe("claimPorts", () => {
+ const server = net.createServer();
+
+ before(async () => {
+ // BASE+1 is listened on, BASE+2 has the lock of a process that has ended,
+ // BASE+3 has the lock of this live process.
+ await new Promise((resolve) => server.listen(BASE + 1, "127.0.0.1", resolve));
+ mkdirSync(LOCKS, { recursive: true });
+ const ended = spawnSync(process.execPath, ["-e", ""]).pid;
+ writeFileSync(lockFile(BASE + 2), String(ended));
+ writeFileSync(lockFile(BASE + 3), String(process.pid));
+ });
+
+ after(() => {
+ server.close();
+ // BASE+2's lock is gone unless the test failed.
+ for (const port of [BASE + 2, BASE + 3]) rmSync(lockFile(port), { force: true });
+ });
+
+ test("runs at once get disjoint ports, passing over a port in use and a live lock", async () => {
+ const claims = await Promise.all(Array.from({ length: CHILDREN }, claimInChild));
+ await Promise.all(claims.map((c) => c.release()));
+ const all = claims.flatMap((c) => c.ports);
+ for (const c of claims) assert.equal(c.ports.length, 3);
+ const free = [BASE, BASE + 2, ...Array.from({ length: 3 * CHILDREN - 2 }, (_, i) => BASE + 4 + i)];
+ assert.deepEqual(
+ all.sort((x, y) => x - y),
+ free,
+ "the free ports, each once, BASE+2's stale lock taken over",
+ );
+ for (const port of all) assert.ok(!existsSync(lockFile(port)), `the lock on ${port} is removed at exit`);
+ assert.ok(existsSync(lockFile(BASE + 3)), "a live process's lock is left alone");
+ });
+
+ test("too few free ports is refused, and the ports found are let go", async () => {
+ await assert.rejects(claimPorts(2, { from: BASE, tries: 2 }), /found 1 free DevTools port\(s\) of the 2 needed/);
+ assert.ok(!existsSync(lockFile(BASE)), "the one port found is unlocked again");
+ assert.ok(!existsSync(lockFile(BASE + 1)), "the port in use is not left locked");
+ });
+
+ test("a claim left unfinished by a run that ended is taken over", async () => {
+ const claiming = path.join(LOCKS, "claiming");
+ mkdirSync(claiming);
+ const old = new Date(Date.now() - 60_000);
+ utimesSync(claiming, old, old);
+ const [port] = await claimPorts(1, { from: BASE });
+ assert.equal(port, BASE);
+ assert.ok(!existsSync(claiming), "the claiming folder is removed after the claim");
+ });
+});
diff --git a/try-help-addin.bat b/try-help-addin.bat
new file mode 100644
index 000000000..c36446d43
--- /dev/null
+++ b/try-help-addin.bat
@@ -0,0 +1,27 @@
+@echo off
+@pushd "%~dp0"
+rem Try the help add-in by hand: opens an IDE on your desktop with add-in\
+rem built and loaded, and waits until you close it.
+rem
+rem The add-in goes into a copy of the twinBASIC install in the temp folder,
+rem never into the install's addins\ or %APPDATA%\twinBASIC\addins\, and the
+rem IDE gets an APPDATA of its own. The IDE's registry entries and the
+rem add-in's saved settings are put back once the IDE is closed. The pane's
+rem pages come from docs\_site, so run build.bat first.
+rem
+rem THIS IS NOT ONE OF THE GATES: it needs a twinBASIC install and Windows.
+rem Keep it out of build.bat, check.bat, test.bat and both CI workflows.
+rem
+rem Arguments are passed straight through:
+rem
+rem try-help-addin.bat --project test\addin\helphost the project to open
+rem try-help-addin.bat --port 9600 another DevTools port
+rem
+rem Exit: 0 the IDE was closed and the registry is as it was found, 1 the
+rem add-in did not build or the project does not compile, 2 the tool could
+rem not run, 3 the registry or the work folder was not put back.
+node scripts/try_help_addin.mjs %*
+@rem popd resets ERRORLEVEL, so capture it first.
+@set "TRY_HELP_ADDIN_ERR=%ERRORLEVEL%"
+@popd
+@exit /b %TRY_HELP_ADDIN_ERR%