diff --git a/BUGS-TO-REPORT.md b/BUGS-TO-REPORT.md index cc9917c14..3c931e13c 100644 --- a/BUGS-TO-REPORT.md +++ b/BUGS-TO-REPORT.md @@ -310,7 +310,7 @@ The implementation is accepted, as it is without the attribute. If `[PreserveSig **Additional context** BETA 983 gives the second message as TB5000 instead of TB65535 (the reproducer, checked there, does not report TB65535). Severity: an interface the language lets you declare cannot be implemented at all, and the diagnostic asks for the signature that is already written. - + --- @@ -977,3 +977,40 @@ What did not reproduce it: nothing in the project is needed. The console project Severity: low. No difference in a running program was looked for. Builds are not reproducible: two builds of one project cannot be compared, cached or signed by hash, and now and then the code itself differs. + +--- + +## Reading `Parent` of the file system's root folder crashes the compiler + +**Describe the bug** +In an IDE add-in, reading `Parent` of the virtual file system's root folder, `Host.FileSystem.RootFolder.Parent`, crashes the compiler, and the add-in with it. The read itself crashes, before the value is used: `Dim o As Object = Host.FileSystem.RootFolder.Parent`, `Dim f As Folder = Host.FileSystem.RootFolder.Parent`, `Host.FileSystem.RootFolder.Parent Is Nothing` and `TypeName(Host.FileSystem.RootFolder.Parent)` all crash, and the line the add-in printed just before the read is the last one printed. The IDE restarts the compiler, which loads the add-in and crashes again, 3 or 4 times in a run. + +**To Reproduce** +Steps to reproduce the behavior: +1. Open `rootfolder-parent-crash.twinproj` (attached as `rootfolder-parent-crash.zip`). It is an add-in whose `Host_OnProjectLoaded` is: + ``` + Private Sub Host_OnProjectLoaded() + Host.DebugConsole.PrintText "before Parent" + Dim o As Object = Host.FileSystem.RootFolder.Parent + Host.DebugConsole.PrintText "after Parent" + End Sub + ``` +2. Build it (win32). The add-in crashes the compiler every time it loads, so use an IDE whose add-ins you can restore. Copy `Build\RootfolderParentCrash_win32.dll` into `%APPDATA%\twinBASIC\addins\win32\` and restart the IDE. +3. Open any project. The DEBUG CONSOLE prints `before Parent`, and the compiler crashes. `after Parent` is never printed. +4. To get the IDE working again, close it and delete `RootfolderParentCrash_win32.dll` from `%APPDATA%\twinBASIC\addins\win32\`. + +**Expected behavior** +`Parent` of the root is `Nothing`, as for the root of any object model, so step 3 prints `before Parent` and `after Parent`. At worst the read raises an error that the add-in can handle, and does not take the compiler down. + +**Desktop:** + - OS: Windows 10 Pro 22H2 (build 19045) + - twinBASIC compiler version: BETA 997 + +**Additional context** +Also on BETA 983 and 995, identically. + +What did not reproduce it: `Host.CurrentProject.RootFolder.Parent`, the parent of the project's folder, reads fine, and its `Path` is `twinbasic:/`, the file system's root. An add-in that reads neither does not crash. + +Severity: crash. The crash is in the read, so no `On Error` handler helps, and an add-in that walks up the tree with `Parent` until `Is Nothing` crashes the compiler on every start, until its DLL is removed. The workaround is to stop at the folder whose `Path` is `twinbasic:/` without reading its `Parent`. + + diff --git a/WIP.HelpAddin.md b/WIP.HelpAddin.md index b96ba0c2d..a24acd445 100644 --- a/WIP.HelpAddin.md +++ b/WIP.HelpAddin.md @@ -5,12 +5,13 @@ add-in that shows the documentation for the symbol under the cursor, and the har tests IDE add-ins by machine, which the add-in is developed against. **Status.** Stages 1 to 3 stand. Stage 4, the add-in itself in [add-in/](add-in/), has -increments 1 to 7 built and tested: F1 to a page, the help pane, the compiler's hover to tell +increments 1 to 9 built and tested: F1 to a page, the help pane, the compiler's hover to tell which `Add`, a name with no page shown by its declaration and `[Description]`, offline -help served from an archive in the DLL, hover help, and a detached window. +help served from an archive in the DLL, hover help, a detached window, a settings panel, +and only the packages the project references. - `addin-test.bat` operates Samples 10 and 15 end to end and leaves the registry as it found it. -- All of Stage 2's questions, P1 to P19, are answered. Most are held by probe lanes that +- All of Stage 2's questions, P1 to P21, are answered. Most are held by probe lanes that fail when a later IDE build behaves differently. - Every build publishes `tB/symbols.json`, 5,536 names at 4,086 URLs, under a drift guard that fails the build when one of its URLs goes. @@ -25,7 +26,7 @@ one of three kinds: - stated plainly: read in the IDE's code or registry; - marked *(reported)*: from a static reading or a harvested finding, not re-checked; -- marked with a probe number (**P1** to **P19**): only running the IDE can settle it. +- marked with a probe number (**P1** to **P21**): only running the IDE can settle it. [Stage 2](#stage-2-probes-that-decide-the-design) lists them. ## Goals @@ -613,7 +614,7 @@ registry, `IDESettings` included through hashes, is identical around the run. Most probes are a small add-in plus a scenario. P5, P11 and P13 need only CDP and the file system. Record every answer in this file with the build number it was measured on. -All nineteen questions are answered. +All twenty-one questions are answered. **A probe whose answer something else rests on becomes a lane**: its add-in in `test/addin/probes//`, its scenario beside the others, listed in `lanes.mjs`, with each @@ -639,6 +640,10 @@ run, and the failure says what to update. The lanes and what rests on each: nothing, which reads `TB_ADDIN_TEST`. - [widgets.test.mjs](test/addin/widgets.test.mjs) (P16, P17): the two routes to hover help increment 6 weighed; the mouse route is the one built. It prints the P16 timings with the tests. +- [references.test.mjs](test/addin/references.test.mjs) (P20): increment 9's route to the + packages a project references. +- [parent.test.mjs](test/addin/parent.test.mjs) (P21): the WARNING on the FileSystemItem + page's **Parent**, and the reproducer of its entry in BUGS-TO-REPORT.md. A probe that settles a question once stays in scratch. So do the two P3 checks that need the network or a changed WebView2: the live site in the frame, and the colour scheme with @@ -667,6 +672,8 @@ The table gives each answer in brief; the sections above have the detail. | P17 | Can the page's Monaco take a second hover provider for `twinbasic`, as the add-in would register it through the page? **Answered, BETA 997: yes.** Its text is shown in the IDE's own hover, above the compiler's, from a synchronous answer and from a promise answered 500 ms later, and goes once the provider is disposed. Registered for `"*"`, which matches less closely than the IDE's `"twinbasic"`, its text is shown below the compiler's. A link in it is drawn as ``, and a click listener on the window in the capture phase receives the click, and with `preventDefault` and `stopPropagation` nothing is opened. The IDE's hover shows after 1,000 ms (`hover:{delay:1e3}`) and is off when the user turns off *showExtraInformationWhenHovering* | Stage 4, increment 6: the mouse route, the owner's second exception to page internals | | P18 | What hides a hover once the mouse slides down it past the code editor's bottom? **Answered, BETA 997: the IDE's dock.** Its resizer along the editor's edge (`z-index: 1000`) and its drop targets lie over the hover, transparent, and take the mouse; Monaco's `_onEditorMouseLeave` then hides the hover. Monaco alone keeps a hover the mouse is over: its DOM stays inside the editor's, and the hover is sticky. CDP's mouse reproduces it only in steps small enough to land on the 6 px resizer; the owner's own mouse found it, under a watcher on the IDE's DevTools port that recorded `_hideWidgets`'s stack | Stage 4, increment 6: the add-in draws every hover above the dock, hover help on or off | | P19 | Can an add-in show a window of its own, a Form holding the WebView2 package's control? **Answered, BETA 997: yes** ([detach.test.mjs](test/addin/detach.test.mjs), `probes/detach`). A minimal hand-written `.tbform` builds, given `_className` and `_clsid` on the form and the control. `Show vbModeless` gives a visible top-level window, owned by the compiler's process (`twinBASIC_win32_noDEP.exe`, a child of `twinBASIC.exe`), with no message loop of the add-in's own; the control is ready about 250 ms after the first Show, 60--80 ms for a later form. A page loaded with `NavigateToString` posts to the add-in and receives what it posts, and an iframe in it loads an `http://localhost` page. Hide and Show keep the page; Unload and a new form work; the IDE ending leaves no `msedgewebview2.exe` of the window's behind. **The harness gives the IDE `WEBVIEW2_USER_DATA_FOLDER` and `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`, the compiler inherits them, and they override the control's own options: with them left in place the control fails, error `8007139F`** (which of the two, not isolated). With no user data folder set, the control uses `bin\twinBASIC_win32_noDEP.exe.WebView2`, which the IDE has made already | Stage 4, increment 7: the detached window, and the variables it removes under the test switch | +| P20 | Can an add-in learn, through the public API, which packages its project references? **Answered, BETA 995 and 997: yes** ([references.test.mjs](test/addin/references.test.mjs), `probes/references` and its three hosts `probes/references-*`). `/Packages`, reached with `For Each` from `Host.FileSystem.RootFolder` (`twinbasic:/`), holds a folder for each package the project references, newest first, then VBRUN and VBA, which are always there; a package's own references are folders inside its own `Packages`. A folder is named for the package's project, which is the key the symbol index's `packages` map uses (`VB`, not the reference's symbol `WindowsControlsPackage`; `Assert`, not `TwinBasicAssertions`); `VBComDlg`, which only WinNativeCommonCtls references, is the one name the map lacks. `IsPackagesFolder` is true for every `Packages` folder and false for a package's. A package is one folder shared by every list it is in, and its `Path` and `Parent` name the first one it was found in, so only `Name` identifies it. The list is complete when `Host_OnProjectLoaded` runs. Applying a reference added or removed on the References page restarts the compiler with no question asked, and the new add-in instance's list shows the change. A type library is a file in `References`, not a package. | Stage 4, increment 9: the packages the add-in offers | +| P21 | What does reading `Parent` of the file system's root do? **Answered, BETA 983, 995 and 997: it crashes the compiler** ([parent.test.mjs](test/addin/parent.test.mjs), `probes/parent`, one IDE per case, the case passed in `TB_P21_CASE`). The read itself crashes, before the value is used: into an `Object` or a `Folder`, in `Is Nothing`, in `TypeName`. Each compiler the IDE restarts loads the add-in and crashes again. The project folder's `Parent` reads, and is the root (`twinbasic:/`). | no add-in here reads the root's `Parent` (P20's tells the root by its path); the WARNING on FileSystemItem's **Parent**; a BUGS entry | ### Stage 3: the symbol index, generated by the docs build @@ -1002,7 +1009,7 @@ harness about a second against opening a new IDE. byte for byte, a folder's 301, a 404 and `HEAD`. 6. **Hover help. Built**, tested by the `help` and `help-offline` lanes on BETA 997, with the owner's choices (2026-10-06): links in the IDE's hover under the mouse, behind one - *Hover help* checkbox in the pane's bar, off by default and kept with `SaveSetting` + *Hover help* checkbox in the settings panel (increment 8), off by default and kept with `SaveSetting` (`Hover`, `On`). It shows a link per page the name has, at most five, labelled with the name, its kind and where it is, as in *Help: MsgBox function (VBA.Interaction)* (the owner's wording, 2026-10-07; `SymbolIndex.Place`); a click shows that page in the pane, @@ -1157,8 +1164,30 @@ harness about a second against opening a new IDE. caption (no read-back of the DWM attribute), Alt+F4, the registry read, maximizing on a monitor other than the primary one (the lab machine has one), and the move itself, whose loop needs the real mouse, which the harness's desktop has none of. -8. **Later:** offering only the packages the project references; how a user gets an add-in - with the archive. +8. **Settings. Built**, tested by the `help` and `help-offline` lanes on BETA 997, at the + owner's choice (2026-10-07): a gear at the right end of the pane's bar, and on the detached + window's title bar (in its bar under the Windows caption), opens a drop-down panel of + settings, one label row each. A click outside it, Esc, or the gear closes it; changing a + setting leaves it open. Each view has its own panel; the settings themselves go through + the window's messages as before. In the pane a transparent shade under the panel takes a + click outside it. In the window a press over the page's frame never reaches the page, + because the frame is cross-site there and runs in a process of its own, so the window's + `blur` closes the panel as well. The lanes' pane frame is on the IDE page's site, so one + case points it at `127.0.0.1` to check the shade with a cross-site frame. +9. **Only the packages the project references. Built**, tested by the `help` and + `help-offline` lanes on BETA 997, at the owner's choice (2026-10-07): F1, the hover links + and the search offer the language's own entries and the packages the project references; + an *All packages* row in the settings panel, off by default and kept with `SaveSetting` + (`Packages`, `All`), offers every package. `LoadPackages` reads the references at + `Host_OnProjectLoaded` from the virtual file system (P20): the folders of the project's + `Packages` folder and, through each, of its own, keyed on `Name`; a reference change + restarts the compiler, so that is enough. If the list cannot be read, every package is + offered, and the loaded line says so. `SymbolIndex.Restrict` limits `Find` and `Search`; + `FindDeclared` is not limited, since the compiler's hover names the declaring package. + F1 on a name only an unreferenced package documents says *No help for 'X' in the packages + this project references*. +10. **Later:** how a user gets an add-in with the archive: the owner's plan (2026-10-07) is to + build it in CI with everything else, once the compiler runs on Linux there. **Lookup:** @@ -1218,8 +1247,11 @@ The add-in's code skeletons are written in Stage 4 against the compiler, with te ### Stage 5: shipping -- Build both bitnesses, which `buildAddin` does (P7). Add a documentation page under - [docs/IDE/AddIns/](docs/IDE/AddIns/). +- Build both bitnesses, which `buildAddin` does (P7). The documentation page is + [docs/IDE/AddIns/Help.md](docs/IDE/AddIns/Help.md); its pictures are taken by + `scripts/shoot_help_addin.mjs` from `test/addin/helpdemo`, so retake them after a change the + pictures show, and update the page's *Getting the add-in* once increment 10 settles how users + get it. - Distribution is upstream's decision: the community add-ins list, or bundled with the IDE. - Take to upstream, with the probe results as evidence: the shortcut bug; a call to open a URL; a way to ask the compiler about the symbol at a position, whose answer hover already diff --git a/WIP.md b/WIP.md index 50f755f6f..6d7bbd1cb 100644 --- a/WIP.md +++ b/WIP.md @@ -460,9 +460,10 @@ Why the report separates the wedged task from the merely blocked ones, and why - `book.bat` — renders the PDF from `docs\_site-pdf\book.html` via `node book\render-book.mjs` into `docs\_pdf\twinBASIC Book.pdf`. Run `build.bat` first to populate `_site-pdf/`; `book.bat` refuses a tree older than its sources rather than rendering the previous book (see [The book refuses a stale source tree](WIP.Build.md#the-book-refuses-a-stale-source-tree)). - `examples.bat` — compiles the documentation's own twinBASIC code samples, every `tb` fence marked `check_build`, and reports the ones the compiler refuses against the line in the page they came from; a statement sample also marked `check_run` is built and run, and what it prints is checked against the page. Needs a twinBASIC install and Windows, so it is outside every gate and outside CI; ~110 s over the 1,197 samples marked (33 of them `check_run`). `--build` also builds each project that compiles clean, and `--llvm` builds it with LLVM (see [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md)). Two modes need no compiler at all: `--census` classifies every fence and says how many classifiable ones are still unmarked, and `--report ` groups a saved `--propose --json` survey by diagnostic, section and unresolved name. `--propose` itself does compile. See [Compiling the reference's own code samples](#compiling-the-references-own-code-samples) and [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). -- `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; about two minutes for the fifteen lanes today (BETA 997): Samples 10 and 15, the eleven probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md), and the help add-in in `add-in/` twice, built with its committed copy of the symbol index, `add-in/Resources/SYMBOLS/symbols.json`, which a local build rewrites whenever the index changes. The `help` lane serves the built `docs/_site` on localhost to the add-in's pane; `help-offline` builds the add-in with a help archive of `docs/_site-offline` and the pane's pages come from the add-in's own server. So run `build.bat` first. Exit 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. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. +- `addin-test.bat` — tests IDE add-ins by operating an IDE: every lane in `test/addin/lanes.mjs` builds the add-ins it tests into a private copy of the install, opens a project and checks what the add-in does. Outside every gate and outside CI for the same reasons as `examples.bat`; about two minutes for the seventeen lanes today (BETA 997): Samples 10 and 15, the thirteen probe lanes behind Stage 2's answers in [WIP.HelpAddin.md](WIP.HelpAddin.md), and the help add-in in `add-in/` twice, built with its committed copy of the symbol index, `add-in/Resources/SYMBOLS/symbols.json`, which a local build rewrites whenever the index changes. The `help` lane serves the built `docs/_site` on localhost to the add-in's pane; `help-offline` builds the add-in with a help archive of `docs/_site-offline` and the pane's pages come from the add-in's own server. So run `build.bat` first. Exit 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. See [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler) for its rules. - `ide-test.bat` --- the same runner for scenarios that operate the IDE itself rather than an add-in: every lane in `test/ide/lanes.mjs`, base port 9660. Same exit codes, same standing outside every gate and outside CI, same rules. - `try-help-addin.bat` --- opens one IDE on the user's desktop with the help add-in built and loaded, to try it by hand, and puts the registry back once it is closed (see [Driving the twinBASIC compiler](#driving-the-twinbasic-compiler)). Needs `build.bat` first. Exit 0 closed and put back, 1 the add-in or the project did not compile, 2 the tool could not run, 3 not put back. +- `node scripts/shoot_help_addin.mjs` --- writes the help add-in's documentation screenshots, `docs/IDE/AddIns/Images/Help_.png` (Pane, Hover, Search, Choices, Description, Settings, Window, Toolbar): it builds the add-in as `try-help-addin.bat` does, opens `test/addin/helpdemo` in an IDE on a **private** desktop (dark theme, `TB_DOCS_HELP_REAL_TITLEBAR=0`, the page overridden to 1280x880 at 2x), operates it and captures each picture with `Page.captureScreenshot` and a `clip`. A picture is rewritten only when its bytes differ, and a second run reports every one `unchanged`. The page's animations and carets are switched off through a style sheet put into the document and every shadow root; no picture holds the DEBUG CONSOLE (its lines carry a time). **The Windows user name must never be in a published picture**: the visible text of the page is searched for it before a picture is kept, and the run fails when it is there. The tool finds each name it presses F1 on by searching the demo's source, not by line. The add-in is built on a port of its own (`Lane.addAddin`'s `port`): the building IDE's WebView2 process can hold its port after the IDE ends for longer than `launchIde`'s ten seconds. No wrapper; outside every gate and outside CI, as `try-help-addin.bat` is. Exit 0 every picture written or unchanged, 1 a picture failed, 2 the tool could not run, 3 not put back. Run `build.bat` first. - **The help archive is written by `build.bat` itself, as its last step.** `builder/help-archive-step.mjs` writes `add-in/Resources/HELP/site.zip` from `docs/_site-offline` through `lib/help-archive.mjs`, after the Gantt injection (which rewrites `BuildInfo.html` in the tree), and reads the file back to verify it. A mismatch fails the build (exit 1). The help add-in embeds the zip as a resource and serves the pages from it with its own server; a build of the add-in without it uses the live site. The file is gitignored (about 21 MB), so a build of the add-in from a clean clone is the online one until `build.bat` has run. **`--no-help-archive` turns the step off, and both CI workflows pass it** (`check_ci_workflows.mjs` requires it, in `ALLOWED.requiredBuildFlags`). It runs only for the guarded source tree (`docs`) built into the default `docs/_site-offline` (not another `--dest`, so `compare_trees` and `check_links_diff` leave it alone), never under `--serve` or `--dry-run`, never without an offline tree, and not keyed on `$CI`. There is no command-line tool for the archive: the `help-offline` lane makes its own with `writeHelpArchive`. The zip's format is fixed by the add-in's reader, which does no inflating ([WIP.HelpAddin.md](WIP.HelpAddin.md), Stage 4, increment 5). - `node scripts/bug_repro.mjs` --- the reproducer projects under `bugs//` for the entries of [BUGS-TO-REPORT.md](BUGS-TO-REPORT.md): `new`, `pack` (impexp, then the zip, written in Node), `compile`, `build` and `run` through `tbbuild` and `tbrun`, `vb6` (builds the optional `vb6/` VB6 project beside the twinBASIC one in a temp copy, runs its exe on a private desktop, and prints its `out.txt`; a project with a form or a user control is built without Unattended Execution, which VB6 refuses for it; `pack` zips its sources into `-vb6.zip`; VB6 is started only by `scripts/lib/vb6.mjs`, never from a shell; a graphical defect's `images` key makes `run` and `vb6` keep what the `PngDump` modules of `test/repro-templates/png/` draw as `images/-tb.png` and `-vb6.png`, with a `-compare.png` of both, tested by `test/png.test.mjs`), `file` (moves a filed entry and its reproducer to `bugs/filed//`; `file --marked` does every marked entry), and `verify`, which reads each `bugs/*/repro.json` and `bugs/filed/*/repro.json` and says whether the bug still reproduces on the newest beta; a `lane` reproducer names the `ide-test` or `addin-test` lane and tests that assert its bug, which `verify` runs last through that runner, and a `probe` one a script under `scripts/`. **No reproducer should need a person at the IDE** (owner, 2026-10-05): a bug the IDE has to be operated for gets a lane test, and `manual` is left for what cannot be automated at all. It has no wrapper. **`verify` is run by a person only**, never by a gate or CI: it needs a twinBASIC install, like `examples.bat`. Default port 9440, and `--jobs N` takes N ports from there. Its command-line cases are in `scripts/lib/cli-cases.mjs`, and need no IDE. - `node scripts/vb6run.mjs ` and `node scripts/vb6run.mjs --docs [--only ]` --- build and run VB6 code, so that what a sample prints in twinBASIC can be compared with VB6: a stand-alone file of statements or a `.bas` module, or `--docs`, which builds the documentation's `check_run` fences in VB6 and says for each whether it prints what its page says (`same`, `differs`, `not VB6`, `error`, `refused`; a `projname=` group is built as a project of its own, its `slot=file` fences translated into VB6 `.cls` and `.bas` components). It has no wrapper and is outside every gate and outside CI: it needs VB6 (`--vb6`, `VB6_EXE`, or the standard install folder). **Never start `VB6.EXE` from a shell**: in Git Bash `/make` is rewritten as a path and VB6 answers with a modal box on the desktop; the tool spawns it from Node with an argument array, and a sample that calls `MsgBox` or `InputBox` or contains `End` is refused. Its `Debug.Print` rewrite and its file translation have probes in `test/example-batches.test.mjs`, and its command-line cases are in `scripts/lib/cli-cases.mjs`; both need no VB6. What it relies on is in [WIP.ExamplesBuild.md](WIP.ExamplesBuild.md). diff --git a/add-in/Resources/SHELL/shell.css b/add-in/Resources/SHELL/shell.css index f26fbd86d..ef183f022 100644 --- a/add-in/Resources/SHELL/shell.css +++ b/add-in/Resources/SHELL/shell.css @@ -132,6 +132,19 @@ html[data-chrome="native"] #helpTitleBar { display: none; } +/* The settings gear is an icon in the title bar, 12px as the other buttons' + icons are, and in the search bar, where it is pane.css's. The panel under + it is the body's, not the title bar's, in colour and type. */ +#helpTitleBar #helpSettings svg { + width: 12px; + height: 12px; +} + +#helpTitleBar #helpSettingsPanel { + color: var(--themeToolWindowBodyForeColor); + font: 13px "Segoe UI", system-ui, sans-serif; +} + /* The Maximize button shows Restore while the window is maximized. */ html[data-state="max"] #helpMaximize .maximize, html:not([data-state="max"]) #helpMaximize .restore { diff --git a/add-in/Resources/SHELL/shell.html b/add-in/Resources/SHELL/shell.html index 7d943ac6b..c0e9eda8c 100644 --- a/add-in/Resources/SHELL/shell.html +++ b/add-in/Resources/SHELL/shell.html @@ -13,6 +13,13 @@
TWINBASIC HELP
+
+ + +
@@ -20,11 +27,11 @@
-
+ diff --git a/add-in/Resources/SHELL/shell.js b/add-in/Resources/SHELL/shell.js index 119c39992..fb626c1c3 100644 --- a/add-in/Resources/SHELL/shell.js +++ b/add-in/Resources/SHELL/shell.js @@ -3,12 +3,13 @@ // way is "verb:payload", split at the first colon. // // Page to add-in: ready, search:, enter, pick:, hover:<1|0>, -// attach, browser, frame: (the frame has loaded src), and from the title -// bar drag (start moving the window), minimize, maxrestore, close. +// allpackages:<1|0>, attach, browser, frame: (the frame has loaded src), +// and from the title bar drag (start moving the window), minimize, maxrestore, +// close. // Add-in to page: theme: a line break and , search:, // results:, page:, summary:, hover:<1|0>, hoverdisabled:<1|0>, -// browser:<1|0 enabled>, focus:, chrome: (whether the page -// draws the title bar), state: (the window's). +// allpackages:<1|0>, browser:<1|0 enabled>, focus:, chrome: +// (whether the page draws the title bar), state: (the window's). (function () { var host = window.chrome.webview; var byId = function (id) { return document.getElementById(id); }; @@ -19,6 +20,11 @@ var frame = byId("helpPage"); var browser = byId("helpBrowser"); var hover = byId("helpHover"); + var allPackages = byId("helpAllPackages"); + var settings = byId("helpSettings"); + var settingsWrap = byId("helpSettingsWrap"); + var settingsPanel = byId("helpSettingsPanel"); + var settingsShade = byId("helpSettingsShade"); var titleBar = byId("helpTitleBar"); var attach = byId("helpAttach"); var maximize = byId("helpMaximize"); @@ -45,7 +51,7 @@ } titleBar.addEventListener("mousedown", function (e) { - if (e.button !== 0 || e.target.closest("button")) return; + if (e.button !== 0 || e.target.closest("button, #helpSettingsWrap")) return; endPress(); press = { x: e.clientX, y: e.clientY, timer: setTimeout(startDrag, HOLD_MS) }; }); @@ -56,7 +62,7 @@ }); document.addEventListener("mouseup", endPress); titleBar.addEventListener("dblclick", function (e) { - if (e.button === 0 && !e.target.closest("button")) post("maxrestore"); + if (e.button === 0 && !e.target.closest("button, #helpSettingsWrap")) post("maxrestore"); }); byId("helpMinimize").addEventListener("click", function () { post("minimize"); }); maximize.addEventListener("click", function () { post("maxrestore"); }); @@ -73,6 +79,33 @@ if (row) post("pick:" + row.getAttribute("data-pick")); }); hover.addEventListener("change", function () { post("hover:" + (hover.checked ? "1" : "0")); }); + allPackages.addEventListener("change", function () { + post("allpackages:" + (allPackages.checked ? "1" : "0")); + }); + + // The settings panel opens under the gear, with the shade over the rest of + // the window so that a click anywhere else closes it. A press over the + // page's frame goes to the frame and never reaches the shade, but it takes + // the focus from this page, so the window's blur closes the panel too. Esc + // closes it from wherever in this page the focus is. A setting changed in + // the panel leaves it open. The panel is this view's own: the add-in is told + // of a setting, not of the panel. + function showSettings(open) { + settingsPanel.style.display = open ? "" : "none"; + settingsShade.style.display = open ? "" : "none"; + settings.setAttribute("aria-expanded", open ? "true" : "false"); + } + + settings.addEventListener("click", function () { + showSettings(settingsPanel.style.display === "none"); + }); + settingsShade.addEventListener("click", function () { showSettings(false); }); + window.addEventListener("blur", function () { showSettings(false); }); + document.addEventListener("keyup", function (e) { + if (e.key !== "Escape" || settingsPanel.style.display === "none") return; + showSettings(false); + settings.focus(); + }); browser.addEventListener("click", function () { post("browser"); }); attach.addEventListener("click", function () { post("attach"); }); frame.addEventListener("load", function () { @@ -86,13 +119,18 @@ byId("theme").textContent = line < 0 ? "" : payload.slice(line + 1); } - // Where Attach is: a button in the title bar when the page draws one, a - // button in the search bar, before the Hover help box, when the window has - // the Windows title bar. + // Where Attach and the settings gear are: buttons in the title bar when the + // page draws one, in the search bar, the gear last, when the window has the + // Windows title bar. function setChrome(chrome) { root.setAttribute("data-chrome", chrome); - if (chrome === "native") byId("helpBar").insertBefore(attach, byId("helpHoverLabel")); - else titleBar.insertBefore(attach, byId("helpMinimize")); + if (chrome === "native") { + byId("helpBar").appendChild(attach); + byId("helpBar").appendChild(settingsWrap); + } else { + titleBar.insertBefore(attach, byId("helpMinimize")); + titleBar.insertBefore(settingsWrap, byId("helpMinimize")); + } } // The window's state, which the Maximize button shows. @@ -133,6 +171,7 @@ case "summary": showSummary(payload); break; case "hover": hover.checked = payload === "1"; break; case "hoverdisabled": hover.disabled = payload === "1"; break; + case "allpackages": allPackages.checked = payload === "1"; break; case "browser": browser.disabled = payload !== "1"; break; case "focus": search.focus(); break; case "chrome": setChrome(payload); break; diff --git a/add-in/Resources/STYLESHEETS/pane.css b/add-in/Resources/STYLESHEETS/pane.css index 3f96dc53e..cb9303653 100644 --- a/add-in/Resources/STYLESHEETS/pane.css +++ b/add-in/Resources/STYLESHEETS/pane.css @@ -11,6 +11,7 @@ are 23 to it, and seven of them overflowed a list 159.25px tall. */ #helpWrap { --helpRow: 22px; + position: relative; display: flex; flex-direction: column; height: 100%; @@ -26,7 +27,43 @@ padding: 6px; } -#helpHoverLabel { +/* The gear at the bar's right end opens the settings panel, a column of rows + under it, right-aligned with it. While the panel is open the shade lies + over the whole pane, the page's frame included, so that a click anywhere + else reaches the shade and closes the panel; the gear's holder and the panel + are above the shade. A row is a label holding a control and its text. */ +#helpSettingsWrap { + position: relative; + z-index: 101; + display: flex; + flex: 0 0 auto; +} + +#helpSettingsShade { + position: absolute; + inset: 0; + z-index: 100; +} + +#helpSettingsPanel { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 101; + display: flex; + flex-direction: column; + gap: 6px; + box-sizing: border-box; + min-width: 160px; + padding: 8px 10px; + color: var(--themeToolWindowBodyForeColor); + background: var(--themeGeneralPanelBackColor); + border: var(--themePropertySheetValueBorderColor); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + +#helpHoverLabel, +#helpAllPackagesLabel { display: flex; align-items: center; gap: 4px; @@ -36,7 +73,8 @@ #helpSearch, #helpBrowser, #helpDetach, -#helpAttach { +#helpAttach, +#helpSettings { font: inherit; color: inherit; background: transparent; @@ -51,11 +89,20 @@ #helpBrowser, #helpDetach, -#helpAttach { +#helpAttach, +#helpSettings { flex: 0 0 auto; cursor: pointer; } +#helpSettings svg { + display: block; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; +} + #helpBrowser:disabled { opacity: 0.5; cursor: default; @@ -64,6 +111,8 @@ #helpBrowser:enabled:hover, #helpDetach:hover, #helpAttach:hover, +#helpSettings:hover, +#helpSettings[aria-expanded="true"], #helpResults .hit:hover { background: var(--themeTreeItemHoverBackColor); } diff --git a/add-in/Sources/HelpAddIn.twin b/add-in/Sources/HelpAddIn.twin index eb5a64ad4..0e99e14e3 100644 --- a/add-in/Sources/HelpAddIn.twin +++ b/add-in/Sources/HelpAddIn.twin @@ -5,11 +5,14 @@ ' declared, through its hover (CompilerHover), so that c.Add finds ' Collection.Add when c is a Collection; a selection is looked up as it is. ' The pages come from the add-in's own server when it was built with the help -' archive (HelpServer), and from the live site otherwise. The pane's Hover -' help box turns on links to the pages in the IDE's hover under the mouse -' (HoverHelp). The pane's Detach button moves the whole pane into a window of -' the add-in's own (HelpWindow), which can go to another monitor; the window's -' Attach button brings it back. While it is detached everything that would show +' archive (HelpServer), and from the live site otherwise. The gear at the +' right end of the pane's bar opens a panel of settings, each view with its +' own; its Hover help box turns on links to the pages in the IDE's hover under +' the mouse (HoverHelp), and its All packages box offers every package of the +' index in place of the packages the project references, which F1, the hover +' links and the search offer otherwise (LoadPackages). The pane's Detach +' button moves the whole pane into a window of the add-in's own (HelpWindow), +' which can go to another monitor; the window's Attach button brings it back. While it is detached everything that would show ' in the pane shows in the window, and the pane's frame is not loaded. The pane ' keeps the state either way: the window is another view of it. The window's ' title bar is one row drawn in its page, in the style of a tool window's @@ -44,7 +47,13 @@ Private Class HelpAddIn Private Server As HelpServer ' Nothing unless the add-in has the archive Private WithEvents Hovering As HoverHelp ' Nothing without an index Private HoverBox As HtmlElement ' the pane's Hover help checkbox + Private AllBox As HtmlElement ' the pane's All packages checkbox + Private Referenced As Collection ' the project's packages, keyed by name; Nothing if unknown Private DetachButton As HtmlElement + Private SettingsButton As HtmlElement ' the gear at the bar's right end + Private SettingsPanel As HtmlElement ' the rows the gear opens + Private SettingsShade As HtmlElement ' over the pane while the panel is open + Private SettingsOpen As Boolean ' the pane's panel shows; each view has its own Private WithEvents HelpWin As HelpWindow ' Nothing until the first Detach, then kept Private Detached As Boolean ' the window shows the help, the pane is hidden Private WindowShown As Boolean ' Detached, and the window is not hidden @@ -53,6 +62,7 @@ Private Class HelpAddIn Private LoadedFrame As String ' the src the pane's frame was given Private SearchText As String ' what the search box holds, in either view Private HoverOn As Boolean ' what the Hover help box holds, in either view + Private AllPackages As Boolean ' what the All packages box holds, in either view Private SummaryHtml As String ' what ShowSummary shows Private ShowingSummary As Boolean ' the summary shows in place of the page @@ -63,6 +73,12 @@ Private Class HelpAddIn ' SaveSetting's application name; test/addin/lanes.mjs names it too. Private Const SETTINGS_APP As String = "tbDocsHelp" Private Const SEARCH_LIMIT As Long = 100 + ' The settings button's gear, in the button's text colour; shell.html has + ' the same drawing. + Private Const GEAR_SVG As String = "" ' The window's settings: whether it is detached, whether it is shown, and ' where it is, in pixels. Private Const WINDOW_SECTION As String = "Window" @@ -81,6 +97,9 @@ Private Class HelpAddIn Set HelpButton = Host.Toolbars(0).AddButton("tbDocsHelp", "Help") Host.KeyboardShortcuts.Add "f1", AddressOf OnF1 Dim indexState As String = LoadIndex() + AllPackages = GetSetting(SETTINGS_APP, "Packages", "All", "0") = "1" + Dim packagesState As String = LoadPackages() + RestrictIndex If Not Index Is Nothing Then Set Hovering = New HoverHelp(Host, Index, BASE_URL) StartServer PageUrl = GetSetting(SETTINGS_APP, "Pane", "Page", "") @@ -90,8 +109,8 @@ Private Class HelpAddIn BuildPane ' The scenarios wait for this line, and check the switch in it before ' pressing anything. - Host.DebugConsole.PrintText TAG & "loaded, " & indexState & ", test switch " & _ - IIf(TestSwitchOn(), "on", "off") + Host.DebugConsole.PrintText TAG & "loaded, " & indexState & ", " & packagesState & _ + ", test switch " & IIf(TestSwitchOn(), "on", "off") ' A compiler restart ends the window with the compiler: this instance ' opens it again, unless it was hidden, which it stays until the help is ' asked for (ShowView). @@ -109,6 +128,69 @@ Private Class HelpAddIn Return "no index (" & Err.Description & ")" End Function + ' Reads the packages the project references, for the index to offer: the + ' folders of its Packages folder in the IDE's virtual file system, and of + ' the Packages folder inside each of them, since a package's own references + ' are reached through it. They are named for the packages' projects, as the + ' index names them. Only For Each is safe on the IDE's folders, and an + ' enumeration is finished before the next starts (FoldersIn). Returns what + ' the loaded line says; if the list cannot be read, every package is + ' offered. + Private Function LoadPackages() As String + On Error GoTo Failed + Dim found As New Collection + Dim lists As New Collection + Dim v As Variant, w As Variant + For Each v In FoldersIn(Host.FileSystem.RootFolder) + Dim projectFolder As Folder = v + For Each w In FoldersIn(projectFolder) + Dim child As Folder = w + If child.IsPackagesFolder Then lists.Add child + Next + Next + Dim n As Long + Do While n < lists.Count + n = n + 1 + Dim listed As Folder = lists(n) + For Each v In FoldersIn(listed) + Dim package As Folder = v + If Not found.Exists(package.Name) Then + found.Add package.Name, package.Name + For Each w In FoldersIn(package) + Dim inner As Folder = w + If inner.IsPackagesFolder Then lists.Add inner + Next + End If + Next + Loop + Dim names As String + For Each v In found + names = names & " " & v + Next + Set Referenced = found + Return "packages" & names + Failed: + Set Referenced = Nothing + Return "packages unknown (" & Err.Description & "), all offered" + End Function + + ' The folders in a folder, collected before anything else is enumerated. + Private Function FoldersIn(ByVal parent As Folder) As Collection + Dim list As New Collection + Dim item As FileSystemItem + For Each item In parent + If TypeOf item Is Folder Then list.Add item + Next + Return list + End Function + + ' Limits the index to the project's packages, or to every package when the + ' All packages box is ticked or the project's could not be read. + Private Sub RestrictIndex() + If Index Is Nothing Then Exit Sub + If AllPackages Then Index.Restrict Nothing Else Index.Restrict Referenced + End Sub + ' Serves the pages from the archive, when the add-in was built with one ' (HelpServer). The line it prints names the origin, which the scenarios ' read. @@ -215,15 +297,38 @@ Private Class HelpAddIn Set DetachButton = .ChildDomElements.Add("helpDetach", "button") DetachButton.Properties.innerText = "Detach" DetachButton.AddEventListener("click", AddressOf DetachClicked) - With .ChildDomElements.Add("helpHoverLabel", "label") - Set HoverBox = .ChildDomElements.Add("helpHover", "input") - HoverBox.Properties.type = "checkbox" - HoverBox.Properties.checked = HoverOn - HoverBox.Properties.disabled = Hovering Is Nothing - HoverBox.AddEventListener("change", AddressOf HoverToggled) - .ChildDomElements.Add("helpHoverText", "span").Properties.innerText = "Hover help" + With .ChildDomElements.Add("helpSettingsWrap", "div") + Set SettingsButton = .ChildDomElements.Add("helpSettings", "button") + SettingsButton.Properties.title = "Settings" + SettingsButton.Properties.ariaLabel = "Settings" + SettingsButton.Properties.ariaExpanded = "false" + SettingsButton.Properties.innerHTML = GEAR_SVG + SettingsButton.AddEventListener("click", AddressOf SettingsClicked) + ' One row for each setting: a label holding its control + ' and its text. + Set SettingsPanel = .ChildDomElements.Add("helpSettingsPanel", "div") + SettingsPanel.Properties.style.display = "none" + With SettingsPanel.ChildDomElements.Add("helpHoverLabel", "label") + Set HoverBox = .ChildDomElements.Add("helpHover", "input") + HoverBox.Properties.type = "checkbox" + HoverBox.Properties.checked = HoverOn + HoverBox.Properties.disabled = Hovering Is Nothing + HoverBox.AddEventListener("change", AddressOf HoverToggled) + .ChildDomElements.Add("helpHoverText", "span").Properties.innerText = "Hover help" + End With + With SettingsPanel.ChildDomElements.Add("helpAllPackagesLabel", "label") + Set AllBox = .ChildDomElements.Add("helpAllPackages", "input") + AllBox.Properties.type = "checkbox" + AllBox.Properties.checked = AllPackages + AllBox.AddEventListener("change", AddressOf AllPackagesToggled) + .ChildDomElements.Add("helpAllPackagesText", "span").Properties.innerText = "All packages" + End With End With End With + Set SettingsShade = .ChildDomElements.Add("helpSettingsShade", "div") + SettingsShade.Properties.style.display = "none" + SettingsShade.AddEventListener("click", AddressOf SettingsShadeClicked) + .AddEventListener("keyup", AddressOf PaneKeyUp) Set ResultsElement = .ChildDomElements.Add("helpResults", "listview") Set Results = ResultsElement.Properties.listview Results.setShowScrollbarH(False) @@ -246,6 +351,7 @@ Private Class HelpAddIn Set ResultIds = New Collection LastSearch = "" SearchText = "" + SettingsOpen = False ShowingSummary = False LoadedFrame = "" If Len(PageUrl) > 0 And Not Detached Then @@ -273,6 +379,70 @@ Private Class HelpAddIn If Detached Then PostToWindow "hover:" & IIf(isOn, "1", "0") End Sub + ' Only the packages the project references are offered until the box is + ' ticked, and it stays as it was left. + Private Sub AllPackagesToggled(ByVal eventInfo As HtmlEventProperties) + On Error GoTo Failed + SetAllPackages AllBox.Properties.checked + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Offers every package or the project's, from the pane's box or the + ' window's, and shows it in both. The search runs again, so that its + ' results change at once. + Private Sub SetAllPackages(ByVal isOn As Boolean) + AllPackages = isOn + SaveSetting SETTINGS_APP, "Packages", "All", IIf(isOn, "1", "0") + RestrictIndex + AllBox.Properties.checked = isOn + If Detached Then PostToWindow "allpackages:" & IIf(isOn, "1", "0") + If Index Is Nothing Then Exit Sub + If Len(Trim$(SearchText)) > 0 Then RunSearch Trim$(SearchText) + End Sub + + ' The gear opens the settings panel, or closes it. + Private Sub SettingsClicked(ByVal eventInfo As HtmlEventProperties) + On Error GoTo Failed + ShowSettings Not SettingsOpen + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' A click on the shade is a click outside the panel. The page's frame takes + ' every click that reaches it, so the pane cannot listen for one itself. + Private Sub SettingsShadeClicked(ByVal eventInfo As HtmlEventProperties) + On Error GoTo Failed + ShowSettings False + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Esc closes the panel and gives the gear the focus, from wherever in the + ' pane the focus was; a key in the page's frame does not reach the pane. + Private Sub PaneKeyUp(ByVal eventInfo As HtmlEventProperties) + On Error GoTo Failed + If eventInfo.key = "Escape" And SettingsOpen Then + ShowSettings False + SettingsButton.Properties.focus() + End If + Exit Sub + Failed: + Host.DebugConsole.PrintText TAG & "error " & Err.Number & ": " & Err.Description + End Sub + + ' Shows or hides the settings panel and the shade under it, and tells the + ' gear which it is. A setting changed in the panel leaves it open. + Private Sub ShowSettings(ByVal isOpen As Boolean) + SettingsOpen = isOpen + SettingsPanel.Properties.style.display = IIf(isOpen, "", "none") + SettingsShade.Properties.style.display = IIf(isOpen, "", "none") + SettingsButton.Properties.ariaExpanded = IIf(isOpen, "true", "false") + End Sub + Private Sub Hovering_Pick(ByVal url As String) ShowPage url End Sub @@ -372,7 +542,12 @@ Private Class HelpAddIn Select Case pages.Count Case 0 - Host.ShowNotification "No help for '" & word & "'" + ' A page of a package the project does not reference is said so. + If Index.HasPagesInAnyPackage(word, hover) Then + Host.ShowNotification "No help for '" & word & "' in the packages this project references" + Else + Host.ShowNotification "No help for '" & word & "'" + End If Case 1 ShowPage Index.Url(pages(1)) Case Else @@ -482,10 +657,13 @@ Private Class HelpAddIn If Index Is Nothing Then Exit Sub SearchText = text text = Trim$(text) - If text <> LastSearch Then - LastSearch = text - ShowResults Index.Search(text, SEARCH_LIMIT) - End If + If text <> LastSearch Then RunSearch text + End Sub + + ' Shows the results for text, whether or not they are the ones shown. + Private Sub RunSearch(ByVal text As String) + LastSearch = text + ShowResults Index.Search(text, SEARCH_LIMIT) End Sub Private Sub SearchChanged(ByVal eventInfo As HtmlEventProperties) @@ -698,6 +876,8 @@ Private Class HelpAddIn PickResult CLng(payload) Case "hover" SetHover payload = "1" + Case "allpackages" + SetAllPackages payload = "1" Case "attach" Attach Case "close" @@ -724,14 +904,15 @@ Private Class HelpAddIn End Sub ' Gives the window all that the pane shows: the frame it has, its state, - ' the theme, the search box, the Hover help box, the results, and the page - ' or the summary. + ' the theme, the search box, the Hover help and All packages boxes, the + ' results, and the page or the summary. Private Sub SendWindowState() HelpWin.Post "chrome:" & IIf(NativeFrame, "native", "custom") HelpWin.Post "state:" & StateOfWindow(HelpWin.hWnd) HelpWin.Post "theme:" & ThemeMessage() HelpWin.Post "hoverdisabled:" & IIf(Hovering Is Nothing, "1", "0") HelpWin.Post "hover:" & IIf(HoverOn, "1", "0") + HelpWin.Post "allpackages:" & IIf(AllPackages, "1", "0") HelpWin.Post "search:" & SearchText PostResults If ShowingSummary Then diff --git a/add-in/Sources/SymbolIndex.twin b/add-in/Sources/SymbolIndex.twin index 7716a2cb9..1bb222654 100644 --- a/add-in/Sources/SymbolIndex.twin +++ b/add-in/Sources/SymbolIndex.twin @@ -16,6 +16,7 @@ Private Class SymbolIndex Private Size As Long Private PackageNames As Collection ' the keys of "packages" Private Interfaces As Collection ' "Package.Interface" -> "Package.Class" + Private Offered As Collection ' the packages to offer, keyed by name; Nothing for all ' The parser's state: the whole file, the position in it (from 1), and ' where the next backslash is, so that a string with no escape in it is cut @@ -95,7 +96,35 @@ Private Class SymbolIndex Src = "" End Sub - ' The entries for word, a name or a dotted chain, best first: + ' Limits Find and Search to the entries of the packages in packages, the + ' project names the project references, keyed by name; Nothing offers every + ' package. The language's own entries are always offered. FindDeclared and + ' OnlyIn are not limited: the compiler's hover names the package a name is + ' declared in, and that is the authority. + Public Sub Restrict(ByVal packages As Collection) + Set Offered = packages + End Sub + + ' Whether entry i is offered: the language's own, or in an offered package. + Private Function IsOffered(ByVal i As Long) As Boolean + If Offered Is Nothing Then Return True + If Len(Packages(i)) = 0 Then Return True + Return Offered.Exists(Packages(i)) + End Function + + ' Whether Pages would give word any page if every package were offered, for + ' telling a name no package documents from one only a package the project + ' does not reference documents. + Public Function HasPagesInAnyPackage(ByVal word As String, ByVal hover As String) As Boolean + Dim was As Collection = Offered + Set Offered = Nothing + Dim found As Collection = Pages(word, hover) + Set Offered = was + If found Is Nothing Then Return False + Return found.Count > 0 + End Function + + ' The offered entries for word, a name or a dotted chain, best first: ' - "Container.Name" or "Package.Name", when the last two segments are one; ' - otherwise every entry named Name. A bare name that the language itself ' documents (a statement, a keyword) gives only those; a qualified name @@ -115,7 +144,7 @@ Private Class SymbolIndex Dim bare As String = qual If Left$(bare, 1) = "_" Then bare = Mid$(bare, 2) For i = 0 To Size - 1 - If Names(i) = name Then + If Names(i) = name And IsOffered(i) Then Dim c As String = LCase$(Containers(i)) Dim p As String = LCase$(Packages(i)) If c = qual Or c = bare Or p = qual Then found.Add i @@ -126,7 +155,7 @@ Private Class SymbolIndex Dim language As New Collection For i = 0 To Size - 1 - If Names(i) = name Then + If Names(i) = name And IsOffered(i) Then If Len(Packages(i)) = 0 Then language.Add i Else found.Add i End If Next @@ -193,9 +222,11 @@ Private Class SymbolIndex ' declaring type share its page. hover is the compiler's hover for word, ' or "": when it names a package the index documents, only that package's ' entries count, and when it also names word itself, only those declared - ' where it says. Nothing when hover says word is declared right there, or - ' declared outside the documented packages --- the project, another - ' package, a type library --- which have no pages. + ' where it says, whether or not the project is thought to reference that + ' package. Without a hover only the offered packages (Restrict) count. + ' Nothing when hover says word is declared right there, or declared outside + ' the documented packages --- the project, another package, a type library + ' --- which have no pages. Public Function Pages(ByVal word As String, ByVal hover As String) As Collection If AtDeclaration(hover) Then Return Nothing Dim ids As Collection @@ -224,10 +255,10 @@ Private Class SymbolIndex Return found End Function - ' The entries text names, for the help pane's search box: those that start - ' with it, then those that contain it, in the index's order, at most limit. - ' Text with a dot in it is matched against labels ("Collection.Add"), - ' otherwise against names. Case is ignored. + ' The offered entries text names, for the help pane's search box: those + ' that start with it, then those that contain it, in the index's order, at + ' most limit. Text with a dot in it is matched against labels + ' ("Collection.Add"), otherwise against names. Case is ignored. Public Function Search(ByVal text As String, ByVal limit As Long) As Collection Dim found As New Collection Dim needle As String = LCase$(Trim$(text)) @@ -239,11 +270,15 @@ Private Class SymbolIndex Dim hay As String If dotted Then hay = LCase$(Label(i)) Else hay = Names(i) Dim at As Long = InStr(hay, needle) - If at = 1 Then - found.Add i - If found.Count >= limit Then Return found - ElseIf at > 1 And later.Count < limit Then - later.Add i + If at > 0 Then + If IsOffered(i) Then + If at = 1 Then + found.Add i + If found.Count >= limit Then Return found + ElseIf later.Count < limit Then + later.Add i + End If + End If End If Next Dim j As Variant diff --git a/bugs/rootfolder-parent-crash/repro.json b/bugs/rootfolder-parent-crash/repro.json new file mode 100644 index 000000000..65622b0bc --- /dev/null +++ b/bugs/rootfolder-parent-crash/repro.json @@ -0,0 +1,12 @@ +{ + "mode": "lane", + "lane": "addin:parent", + "tests": [ + "case a: Dim o As Object = RootFolder.Parent crashes at the read", + "case b: the same, then o Is Nothing: crashes at the read, so before the test", + "case c: RootFolder.Parent Is Nothing in one expression crashes", + "case d: TypeName(RootFolder.Parent) crashes", + "case e: Dim f As Folder = RootFolder.Parent crashes at the read" + ], + "steps": "WARNING: this add-in crashes the compiler every time it loads, until its DLL is removed. The IDE restarts the compiler, which loads the add-in and crashes again, and so on. Try it in an IDE whose add-ins you can restore.\n1. Open rootfolder-parent-crash.twinproj in the twinBASIC IDE and build it (win32). Copy Build\\RootfolderParentCrash_win32.dll into %APPDATA%\\twinBASIC\\addins\\win32\\ and restart the IDE.\n2. Open any project. Expected: the DEBUG CONSOLE prints before Parent and then after Parent. Actual: it prints before Parent only, and the compiler crashes in the read of Host.FileSystem.RootFolder.Parent. The IDE restarts the compiler, which loads the add-in and crashes again (3 or 4 times in a run).\n3. To get the IDE working again, close it and delete RootfolderParentCrash_win32.dll from %APPDATA%\\twinBASIC\\addins\\win32\\ (or move it out of that folder).\n4. Control: in the add-in's Host_OnProjectLoaded, read Host.CurrentProject.RootFolder.Parent instead. It reads fine, and its Path is twinbasic:/. An add-in that reads neither does not crash.\nAsserted on BETA 983, 995 and 997 by `addin-test.bat --only parent` (P21, the ParentProbe add-in in test/addin/probes/parent, of which this project is a cut-down copy)." +} diff --git a/bugs/rootfolder-parent-crash/rootfolder-parent-crash.twinproj b/bugs/rootfolder-parent-crash/rootfolder-parent-crash.twinproj new file mode 100644 index 000000000..47170b8e3 Binary files /dev/null and b/bugs/rootfolder-parent-crash/rootfolder-parent-crash.twinproj differ diff --git a/bugs/rootfolder-parent-crash/src/Settings b/bugs/rootfolder-parent-crash/src/Settings new file mode 100644 index 000000000..ac6918117 --- /dev/null +++ b/bugs/rootfolder-parent-crash/src/Settings @@ -0,0 +1,46 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "RootfolderParentCrash", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard DLL", + "project.description": "Reproduces: Reading `Parent` of the file system's root folder crashes the compiler", + "project.exportPathIsV2": true, + "project.id": "{5C0D1E8A-6B3F-4A92-9E47-2F81D6A0C3B5}", + "project.name": "RootfolderParentCrash", + "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 + } + ], + "project.settingsVersion": 1, + "project.warnings": { + "errors": [], + "hints": [], + "ignored": [], + "info": [], + "warnings": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/bugs/rootfolder-parent-crash/src/Sources/Startup.twin b/bugs/rootfolder-parent-crash/src/Sources/Startup.twin new file mode 100644 index 000000000..549e2c083 --- /dev/null +++ b/bugs/rootfolder-parent-crash/src/Sources/Startup.twin @@ -0,0 +1,30 @@ +' An add-in. Once a project is open, it prints a line, reads the Parent of +' the virtual file system's root folder (twinbasic:/) into an Object, and +' prints a second line. The compiler crashes in the read, so the second line +' is never printed; the IDE restarts the compiler, which loads the add-in +' again and crashes again. +Module Startup + [DllExport] + Public Function tbCreateCompilerAddin(ByVal Host As Host) As AddIn + Return New ReproAddin(Host) + End Function +End Module + +Private Class ReproAddin + Implements AddIn + Private WithEvents Host As Host + + Public Sub New(ByVal Host As Host) + Set Me.Host = Host + End Sub + + Private Property Get AddIn_Name() As String + Return "RootFolder Parent repro" + End Property + + Private Sub Host_OnProjectLoaded() + Host.DebugConsole.PrintText "before Parent" + Dim o As Object = Host.FileSystem.RootFolder.Parent + Host.DebugConsole.PrintText "after Parent" + End Sub +End Class diff --git a/builder/page-baseline.json b/builder/page-baseline.json index 06237f3db..bcc42e4b1 100644 --- a/builder/page-baseline.json +++ b/builder/page-baseline.json @@ -1,5 +1,5 @@ { "src": "docs", - "pages": 927, - "staticFiles": 250 + "pages": 928, + "staticFiles": 258 } diff --git a/docs/Documentation/Tools.md b/docs/Documentation/Tools.md index a9cd9a638..9da8d2d2d 100644 --- a/docs/Documentation/Tools.md +++ b/docs/Documentation/Tools.md @@ -1271,7 +1271,7 @@ its own, with the copy of the symbol index committed in `add-in/Resources/SYMBOLS/`: `help` with the pages from the built site, and `help-offline` with the pages from the add-in's own server, built with an archive of the built offline tree that the lane writes with the same [help archive](#the-help-archive) writer the build -uses. The fifteen lanes take about two minutes together. +uses. The seventeen lanes take about two minutes together. | Flag | Effect | |---|---| @@ -1354,6 +1354,37 @@ 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). +### shoot_help_addin.mjs +{: #shoot-help-addin } + + node scripts/shoot_help_addin.mjs [--only ] [--out ] [--port N] [--ide ] + +Writes the screenshots of the help add-in's documentation page, `Help_.png`, into +`docs/IDE/AddIns/Images/`: `Pane`, `Hover`, `Search`, `Choices`, `Description`, `Settings`, `Window` +and `Toolbar`. It builds the add-in in `add-in/` into a private copy of the twinBASIC install, +opens the small demo project `test/addin/helpdemo` in an IDE on a private desktop, operates it +and captures each picture, so that one run regenerates every image after a new BETA. The IDE is +set up as a lane of [`addin_test.mjs`](#addin-test) is, with a private `APPDATA` and +`TB_ADDIN_TEST` set to `1`, and it never appears on your desktop. The pane's pages come from the +built site, `docs/_site`, served on `localhost`, so run `build.bat` first. + +The pictures are repeatable: the IDE's page is given a fixed size of 1280 by 880 CSS pixels at +2x, animations and the text caret are switched off, the theme is dark, and no picture shows the +Debug Console, whose lines carry the time they were written. A picture is written only when its +bytes differ from the file already there, and each is reported as `new`, `updated` or +`unchanged`; a second run with nothing changed reports every picture `unchanged`. The Windows user +name must never be in a published picture, so before each one is kept the visible text of the page +it was taken from is searched for the name, and a picture whose page holds it is refused. + +`--only` takes the pictures whose name matches a regular expression. `--out` is the folder the +pictures go to. `--port` is where the search for the IDE's DevTools port starts, 9620 by default; +the tool claims three, as `addin_test.mjs` does: the IDE's, the detached window's, and one for the +IDE that builds the add-in, whose browser process can hold its port for a while after that IDE +ends. The add-in's saved settings (`tbDocsHelp`) are emptied for the run +and, with the IDE's registry entries, put back at the end. + +Exit codes: **0** every picture was written or was unchanged; **1** a picture failed (an element was not found, or the page showed the user name), the add-in did not build, or the demo 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 } diff --git a/docs/Features/Advanced/API-Declarations.md b/docs/Features/Advanced/API-Declarations.md index 5cf6d49dd..5167f0988 100644 --- a/docs/Features/Advanced/API-Declarations.md +++ b/docs/Features/Advanced/API-Declarations.md @@ -95,6 +95,29 @@ Class Counter End Class ``` +On a 32-bit build, a **ThisCall** implementation takes `this` in the ECX register and removes its own arguments from the stack, so native code calls it through the interface's vtable as it calls a C++ virtual method. An error raised in the implementation reaches the native caller as a failure **HRESULT**, as from any member without **[PreserveSig]**. Code compiled with LLVM does not pass the error on yet; see [Language support](../../LLVM/Getting-Started#language-support). + +> [!NOTE] +> In BETA 997, a class cannot implement a member marked [**[PreserveSig]**](../../tB/Core/Attributes#preservesig): the compiler reports the implementation as unmatched. Interfaces of this kind often return a value directly rather than an **HRESULT**, and are declared with **[PreserveSig]** for that reason. For a member that returns a 4-byte value, such as a **Long** or a C `BOOL`, declare it in the interface the class implements as a **Sub** without **[PreserveSig]**, and return the value with [**Err.ReturnHResult**](../../tB/Modules/ErrObject/ReturnHResult). Native code that calls this member as `long __thiscall Count()` gets 3 as its return value: +> +> ```tb check_build projname=calling-conventions-thiscall-return +> [InterfaceId("2C4E8A61-5B3F-4D7E-9C1A-6E0B2F4D8A13")] +> [OleAutomation(False)] +> Interface IItems Extends IUnknown +> Sub Count ThisCall() +> End Interface +> +> Class Items +> Implements IItems +> +> Private Sub IItems_Count ThisCall() Implements IItems.Count +> Err.ReturnHResult = 3 +> End Sub +> End Class +> ``` + +An ActiveX DLL's type library records a **ThisCall** or **FastCall** member as stdcall, and a **CDecl** member as cdecl. `[OleAutomation(False)]` does not leave the interface out of the type library; it clears the flag that marks the interface as compatible with OLE Automation. + ### Callbacks A callback passed to an API must use the convention the API calls it with. Declare a [**Delegate**](../../tB/Core/Delegate) with that convention, use it as the parameter type, and give the target procedure the same keyword. [**AddressOf**](../../tB/Core/AddressOf) of a procedure with a different convention is accepted with a warning only (TB0026). diff --git a/docs/IDE/AddIns/Help.md b/docs/IDE/AddIns/Help.md new file mode 100644 index 000000000..50bf09944 --- /dev/null +++ b/docs/IDE/AddIns/Help.md @@ -0,0 +1,133 @@ +--- +title: Documentation Help +parent: Add Ins +nav_order: 2 +permalink: /tB/IDE/AddIns/Help +--- + +# Documentation Help + +Shows this documentation inside the IDE: press F1 on a name in the code editor, and its reference page opens in a tool window beside the code. + +The add-in is named *twinBASIC Documentation Help* in the IDE. It works from an index of every symbol this documentation describes, built with the documentation itself, so it knows which page documents each function, statement, class and member. With the compiler's help it also tells apart names that are spelled the same: `c.Add` on a **Collection** opens **Collection.Add**, and a bare `Close` opens the **Close** statement. + +## F1 and the help pane + +Put the cursor on a name and press F1. The page that documents the name opens in the **TWINBASIC HELP** tool window. The cursor can be anywhere in the name, or just after it, as it is after typing the name. + +![The code editor showing a module named Inventory, with the cursor on MsgBox in line 19. Beside it, the TWINBASIC HELP tool window shows the MsgBox page: breadcrumbs, the heading, its summary, the syntax and the first parameter.](Images/Help_Pane.png){:width="1143" height="550"} + +The help pane is an ordinary IDE tool window: it can float, or be docked like the other tool windows. The page in it follows the IDE's theme, dark or light, and its links work as they do on the website. Above the page are a search box and three buttons: + +**Open in browser** +: Opens the page on [docs.twinbasic.com](https://docs.twinbasic.com) in the default web browser. + +**Detach** +: Moves the help into a window of its own --- see [The help window](#the-help-window). + +The gear +: Opens the [settings](#settings). + +The **Help** button the add-in adds to the IDE's [toolbar](../Project/Toolbar) shows the pane and puts the cursor in its search box, ready to type. + +![The right end of the IDE's toolbar: the zoom box showing 100%, the PREVIEW button, and the Help button with a half-filled circle as its icon.](Images/Help_Toolbar.png){:width="313" height="36"} + +F1 takes the whole dotted name to the left of the cursor, up to the end of the name the cursor is in: with the cursor on `Add` in `Host.ToolWindows.Add(Name, Id)`, it looks up `Host.ToolWindows.Add`. A selection on one line is looked up exactly as it is selected. F1 on an empty spot shows the pane and puts the cursor in its search box. + +When no page documents the name, the pane stays as it is, and a notification says so: *No help for 'Name'*. + +> [!NOTE] +> F1 also expands or collapses the IDE's signature help when it is showing. F1 does nothing while the keyboard focus is inside the page in the pane: click in the code editor first. + +## Hover help + +With **Hover help** ticked in the [settings](#settings), the information box the IDE shows when the mouse rests on a name gains a link to the name's page, at its top. Clicking the link shows the page in the pane, as F1 does. + +![The mouse pointer resting on Collection in the line Dim names As New Collection. The IDE's information box below it starts with the underlined link Help: Collection class (VBA), followed by the class's declaration and its members.](Images/Help_Hover.png){:width="881" height="492"} + +A link names the symbol, its kind and where it is declared: *Help: MsgBox function (VBA.Interaction)*. A name that several pages document gets a link for each, up to five. A name without a page gets no link. Hover help is off until it is ticked. + +## Searching + +Type in the search box to find a page by its symbol's name. The list shows the names that start with the text first, then those that contain it, with each one's kind. Text with a dot in it is matched against qualified names such as `Collection.Add`. Click a name to show its page, or press Enter for the first one. The clear button at the right of the box empties it and hides the list. + +![The help pane with format typed in its search box. The list below shows Strings.Format, Strings.Format$, Strings.FormatCurrency, Strings.FormatDateTime, Strings.FormatNumber and Strings.FormatPercent, all functions, then OLE.Format and DataObjectFormat.FormatType, properties, and Constants.VbDateTimeFormat, an enum.](Images/Help_Search.png){:width="480" height="262"} + +The search finds symbols, not words in the pages: to search the text of the documentation, use the search on the website. + +## When F1 finds several pages + +Sometimes the name alone cannot say which page is meant. A member called through a variable declared **As Object** is the common case: the object's type is known only when the program runs. F1 then puts the name in the search box and lists every page that documents it, and the page in the pane stays as it was. Click the one that applies. + +![The help pane after F1 on prices.Add, where prices is declared As Object. The search box holds prices.Add, and the list shows six methods: Collection.Add, DataMembers.Add, DataObjectFiles.Add, HtmlElements.Add, KeyboardShortcuts.Add and ToolWindows.Add.](Images/Help_Choices.png){:width="480" height="196"} + +## Your own procedures + +F1 on a name declared in the project itself, or in a package or type library this documentation does not cover, shows a summary in place of a page. The summary gives the declaration, the text of the procedure's [**Description**](../../Core/Attributes#description) attribute, and where the name is declared. + +![The help pane after F1 on TotalPrice. In place of a page it shows the declaration Function TotalPrice ( ByVal prices As Object ) As Double, the description Adds up the prices held in a Dictionary., and the line Declared in Inventory.Inventory.](Images/Help_Description.png){:width="480" height="169"} + +The summary for the procedure above comes from this code: + +```tb +[Description("Adds up the prices held in a Dictionary.")] +Public Function TotalPrice(ByVal prices As Object) As Double +``` + +Without a **Description** attribute, the summary says *No description*. **Open in browser** is unavailable while a summary is shown. + +## Settings + +The gear opens the settings. A click anywhere else, or Esc, closes them. + +![The top of the help pane with the settings open under the gear: a ticked Hover help box and an unticked All packages box.](Images/Help_Settings.png){:width="480" height="149"} + +**Hover help** +: Adds the links described under [Hover help](#hover-help). Off by default. + +**All packages** +: Offers the pages of every package this documentation covers. Off by default: F1, the hover links and the search then offer only the language itself and the packages the project references, so that a name from a package the project does not use never hides the one it does. With this off, F1 on a name that only an unreferenced package documents says *No help for 'Name' in the packages this project references*. + +The settings, the last page shown and the help window's place are kept in the registry, under `HKEY_CURRENT_USER\Software\VB and VBA Program Settings\tbDocsHelp`. After the IDE restarts, the pane shows the page it showed last. + +## The help window + +**Detach** moves the whole pane --- search box, buttons and page --- into a window of its own. The window has a button on the taskbar, can be moved to another monitor, and keeps its position and size from one session to the next. While it is detached, F1, the **Help** button and the hover links show their page in the window. + +![The help window, detached from the IDE. Its title bar reads TWINBASIC HELP, with a grip on the left and Attach, settings, minimize, maximize and close buttons on the right. Under it are the search box, the Open in browser button and the MsgBox page.](Images/Help_Window.png){:width="900" height="700"} + +The window's title bar holds, from the left: + +**Attach** +: Puts the help back in the pane in the IDE, with the same search text and page. + +The gear +: Opens the [settings](#settings). + +**Minimize**, **Maximize**, **Close** +: Work as in any window. **Close** only hides the window: the help stays detached, and the next F1 shows the window again. + +The window is open again the next time the IDE starts, if it was open when the IDE closed. With **Show real OS titlebar** turned on in the IDE's options, the window has an ordinary Windows title bar instead, in the IDE's dark or light colours, and **Attach** and the gear move next to the search box. + +## Getting the add-in + +The add-in is built from its source code, which is in the `add-in` folder of the [documentation's repository](https://github.com/twinbasic/documentation). The folder holds the project's files, not a project file, so the first step makes one. + +1. Pack the folder into a project file with the [Import/Export Tool](../../../Features/Packages/Import-Export-Tool), from the repository's root folder: + + ```batch + python scripts/impexp.py import tbDocsHelp.twinproj add-in + ``` + +2. Open `tbDocsHelp.twinproj` in the twinBASIC IDE and build it. Then set the build target to **win64** and build it again. The two DLLs, `tbDocsHelp_win32.dll` and `tbDocsHelp_win64.dll`, are written to a `Build` folder next to the project file. +3. Copy each DLL to the matching `win32` or `win64` add-ins folder, as [Add Ins](../AddIns/) describes, and restart the IDE. + +An add-in built this way shows the pages from [docs.twinbasic.com](https://docs.twinbasic.com), so the pane needs an internet connection. + +**For help that works offline**, build the documentation before step 1, as [Building and Deployment](../../../Documentation/Development/Building) describes. The build writes a copy of the whole site into the add-in's source folder, `add-in\Resources\HELP\site.zip`, about 24 MB. A DLL built with that file in place serves the pages itself, without the internet. **Open in browser** still opens the website. + +## See Also + +- [Add Ins](../AddIns/) -- installing and loading add-ins +- [Global Search](GlobalSearch) add-in +- [tbIDE Package](../../Packages/tbIDE/) -- the API an add-in is written against diff --git a/docs/IDE/AddIns/Images/Help_Choices.png b/docs/IDE/AddIns/Images/Help_Choices.png new file mode 100644 index 000000000..8f3373abf Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Choices.png differ diff --git a/docs/IDE/AddIns/Images/Help_Description.png b/docs/IDE/AddIns/Images/Help_Description.png new file mode 100644 index 000000000..70f6bc7f0 Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Description.png differ diff --git a/docs/IDE/AddIns/Images/Help_Hover.png b/docs/IDE/AddIns/Images/Help_Hover.png new file mode 100644 index 000000000..a9097d8e4 Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Hover.png differ diff --git a/docs/IDE/AddIns/Images/Help_Pane.png b/docs/IDE/AddIns/Images/Help_Pane.png new file mode 100644 index 000000000..ee09767ab Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Pane.png differ diff --git a/docs/IDE/AddIns/Images/Help_Search.png b/docs/IDE/AddIns/Images/Help_Search.png new file mode 100644 index 000000000..4bd124576 Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Search.png differ diff --git a/docs/IDE/AddIns/Images/Help_Settings.png b/docs/IDE/AddIns/Images/Help_Settings.png new file mode 100644 index 000000000..1f84bde8d Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Settings.png differ diff --git a/docs/IDE/AddIns/Images/Help_Toolbar.png b/docs/IDE/AddIns/Images/Help_Toolbar.png new file mode 100644 index 000000000..42dd513ed Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Toolbar.png differ diff --git a/docs/IDE/AddIns/Images/Help_Window.png b/docs/IDE/AddIns/Images/Help_Window.png new file mode 100644 index 000000000..d4feaaac0 Binary files /dev/null and b/docs/IDE/AddIns/Images/Help_Window.png differ diff --git a/docs/Reference/Built-In/tbIDE/FileSystemItem.md b/docs/Reference/Built-In/tbIDE/FileSystemItem.md index 2d7e6ff6c..b89adc2b9 100644 --- a/docs/Reference/Built-In/tbIDE/FileSystemItem.md +++ b/docs/Reference/Built-In/tbIDE/FileSystemItem.md @@ -35,7 +35,10 @@ The item's name (the last segment of its [**Path**](#path)). **String**, read-on ### Parent {: .no_toc } -The folder that contains this item. **As** [**Folder**](Folder). Read-only. The root folder's **Parent** is **Nothing**. +The folder that contains this item. **As** [**Folder**](Folder). Read-only. The **Parent** of the project's folder, [**Project.RootFolder**](Project#rootfolder), is the file system's root, [**FileSystem.RootFolder**](FileSystem#rootfolder), whose **Path** is `twinbasic:/`. + +> [!WARNING] +> In BETA 997, reading **Parent** of the file system's root crashes the compiler, and the add-in with it. The read itself crashes, before anything is done with the value, so neither `Is Nothing` nor **TypeName** can test it, and each compiler the IDE restarts crashes again when the add-in reaches the read. Tell the root by its **Path**, `twinbasic:/`, rather than by its **Parent**. ### Path {: .no_toc } diff --git a/docs/_book.yml b/docs/_book.yml index 111a234e8..d3b9869ff 100644 --- a/docs/_book.yml +++ b/docs/_book.yml @@ -300,6 +300,11 @@ parts: no_outline_entry: true no_heading_shift: true outline_closed: true + - title: Documentation Help + page: /tB/IDE/AddIns/Help + no_outline_entry: true + no_heading_shift: true + outline_closed: true - title: The Core Language subtitle: Statements, operators, and built-in keywords diff --git a/scripts/lib/cli-cases.mjs b/scripts/lib/cli-cases.mjs index 5113110d8..f8ed12d25 100644 --- a/scripts/lib/cli-cases.mjs +++ b/scripts/lib/cli-cases.mjs @@ -130,6 +130,10 @@ const CASES = [ { 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/shoot_help_addin.mjs", args: ["--help"], exit: 0, stdout: /^usage: node scripts\/shoot_help_addin\.mjs / }, + { tool: "scripts/shoot_help_addin.mjs", args: ["--ide"], exit: 2, stderr: "--ide needs a value\n" }, + { tool: "scripts/shoot_help_addin.mjs", args: ["--ide", ""], exit: 2, stderr: "--ide needs a non-empty value\n" }, + { tool: "scripts/shoot_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" }, @@ -344,6 +348,7 @@ const HELP_TOOLS = { "scripts/addin_test.mjs": null, "scripts/ide_test.mjs": null, "scripts/try_help_addin.mjs": null, + "scripts/shoot_help_addin.mjs": null, "scripts/bug_repro.mjs": null, "scripts/build_dot_metrics.mjs": null, "scripts/build_package_api.mjs": null, @@ -420,6 +425,7 @@ const REFUSALS = { "scripts/addin_test.mjs": ["ide"], "scripts/ide_test.mjs": ["ide"], "scripts/try_help_addin.mjs": ["ide"], + "scripts/shoot_help_addin.mjs": ["ide"], "scripts/bug_repro.mjs": ["ide"], "scripts/build_dot_metrics.mjs": [null], "scripts/build_package_api.mjs": ["out"], @@ -727,6 +733,9 @@ bad("scripts/ide_test.mjs", ["--show", "--hide"], "--show and --hide cannot be g 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"); +bad("scripts/shoot_help_addin.mjs", ["--port", "0"], NOT_PORT(0) + "\n"); +bad("scripts/shoot_help_addin.mjs", ["--port=1.5"], NOT_PORT(1.5) + "\n"); +bad("scripts/shoot_help_addin.mjs", ["--only", "("], REGEX_REASON("--only", "(")); // check_examples prints "check_examples: " before the message. { diff --git a/scripts/lib/tb-lane.mjs b/scripts/lib/tb-lane.mjs index 1f701721d..274097079 100644 --- a/scripts/lib/tb-lane.mjs +++ b/scripts/lib/tb-lane.mjs @@ -124,16 +124,20 @@ export class Lane { * @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 + * @param {number} [o.port] the building IDE's DevTools port, by default the + * lane's. An IDE's WebView2 can keep listening on its port for a while after + * the IDE ends, so a caller that opens the lane's IDE straight after the build + * can build on a port of its own instead. * @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", show = this.show } = {}) { + async buildAddin(src, { arch = "win32", show = this.show, port = this.port } = {}) { if (this.run) throw new Error(`lane ${this.name}: close the open project before building an add-in`); return buildAddin({ ide: this.copy(), src, work: path.join(this.work, `addin${++this.builds}`), - port: this.port, + port, arch, show, appdata: this.appdataDir(), @@ -149,11 +153,12 @@ export class Lane { * @param {object} [o] * @param {string} [o.arch] as for buildAddin * @param {boolean} [o.show] as for buildAddin + * @param {number} [o.port] as for buildAddin * @returns {Promise<{dll: string, arch: string, diagnostics: string[], log: string[]}>} * as buildAddin */ - async addAddin(src, { arch = "win32", show = this.show } = {}) { - const built = await this.buildAddin(src, { arch, show }); + async addAddin(src, { arch = "win32", show = this.show, port = this.port } = {}) { + const built = await this.buildAddin(src, { arch, show, port }); this.placeAddin(built.dll, { arch }); return built; } diff --git a/scripts/shoot_help_addin.mjs b/scripts/shoot_help_addin.mjs new file mode 100644 index 000000000..18cf267d7 --- /dev/null +++ b/scripts/shoot_help_addin.mjs @@ -0,0 +1,745 @@ +#!/usr/bin/env node +// Take the screenshots of the help add-in (add-in/) for its documentation page, +// docs/IDE/AddIns/, from an IDE on a private desktop: one run rewrites every +// image after a new BETA, and rewrites none that has not changed. +// +// node scripts/shoot_help_addin.mjs [options] +// +// No wrapper, and not a gate, for the reasons try-help-addin.bat is not one: it +// needs Windows and a twinBASIC install. It is set up as try_help_addin.mjs +// sets an IDE up (a lane of addin-test, lib/tb-lane.mjs) and keeps its rules, +// so that taking the pictures changes nothing the user owns: +// +// * The add-in is built into a copy of the install made in the work folder, +// and the IDE has an APPDATA of its own there, so none of the user's +// add-ins loads. TB_ADDIN_TEST is on, so Open in browser starts nothing. +// * The IDE's registry entries and the add-in's SaveSetting key (tbDocsHelp) +// are recorded first and put back at the end. The key is also emptied +// for the run: the user's own may say Detached, and then F1 would open the +// window and never the pane. +// * The pane's pages come from the built site, docs/_site, served on +// localhost, so build.bat has to have run. +// +// What makes the pictures repeatable, so that a second run changes no byte: +// +// * The IDE's page is given a fixed size and density, 1280 by 800 CSS pixels +// at 2x, through the DevTools device-metrics override. The IDE lays itself +// out inside that cleanly, whatever the private desktop's size, and a +// capture of a rectangle (Page.captureScreenshot with a clip) is then +// exactly twice the rectangle's size. +// * Animations, transitions, the text caret and Monaco's cursor are turned +// off by a style sheet put into the document and into every shadow root +// (a tool window is one). Otherwise a blink or a fade is in some captures. +// * The theme is dark, as the documentation shows it, and no picture holds +// the DEBUG CONSOLE, whose lines carry the time they were written. +// * The IDE is given the title bar the detached help window draws for +// itself (TB_DOCS_HELP_REAL_TITLEBAR=0), not a native Windows one. +// +// The code in the pictures is test/addin/helpdemo, a small program that reads +// as a reader's own. The tool finds each name it presses F1 on by searching the +// source, so a change to the program does not move a case. +// +// The Windows user name must never be in a published picture. Before a picture +// is kept, the visible text of the page it was taken from (the document, every +// shadow root and the docs page in the pane's frame) is searched for it, and a +// picture whose page holds it is refused. A tooltip is not drawn and is not +// read; the IDE's title tooltip holds the project's path. + +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; +import path from "node:path"; +import { createStaticHandler } from "../builder/static-files.mjs"; +import { + die, + exitOnCrash, + numberOption, + parseCli, + printHelpAndExit, + regexOption, + withUsageError, +} from "../lib/cli.mjs"; +import { REPO_ROOT } from "../lib/repo-paths.mjs"; +import { hoverText, mouseAway, pointOf, restMouse } from "../test/addin/hover.mjs"; +import { frameEval, frameOf, serveLoopback } from "../test/addin/pages.mjs"; +import { attach } from "./lib/tb-cdp.mjs"; +import { consoleMark, linesSince } from "./lib/tb-ide-console.mjs"; +import { removeTree } from "./lib/tb-ide-copy.mjs"; +import { shutdownIde, sleep } from "./lib/tb-ide.mjs"; +import { findIde } from "./lib/tb-install.mjs"; +import { Lane } from "./lib/tb-lane.mjs"; +import { click, listViewItems, openFile, pressKey, setCursor, typeText, waitFor } from "./lib/tb-operate.mjs"; +import { claimPorts } from "./lib/tb-ports.mjs"; +import { deleteSettings, finishTidy, restoreKeys, settingsKey, snapshotKeys, startTidy } from "./lib/tb-registry.mjs"; + +const SETTINGS = "tbDocsHelp"; +const PANE = "tbDocsHelpPane"; +const ADDIN = path.join(REPO_ROOT, "add-in"); +const DEMO = path.join(REPO_ROOT, "test", "addin", "helpdemo"); +const DEMO_FILE = "/Inventory/Sources/Inventory.twin"; +const DEMO_SOURCE = path.join(DEMO, "Sources", "Inventory.twin"); +const SITE = path.join(REPO_ROOT, "docs", "_site"); +const DEFAULT_OUT = path.join(REPO_ROOT, "docs", "IDE", "AddIns", "Images"); + +// The IDE page's size in CSS pixels, and the detached window's. Both at 2x. +const IDE_SIZE = { width: 1280, height: 880 }; +const WINDOW_SIZE = { width: 900, height: 700 }; +const SCALE = 2; + +const USAGE = `usage: node scripts/shoot_help_addin.mjs [--only ] [--out ] [--port N] [--ide ] [-h, --help] + +Builds the help add-in in add-in/ into a copy of the install, opens +test/addin/helpdemo in an IDE on a private desktop, operates it, and writes the +pictures of the add-in's documentation page, Help_.png, at 2x in the dark +theme. A picture is written only when its bytes differ from the file already +there; each is reported as new, updated or unchanged. The IDE's registry entries +and the add-in's saved settings are put back afterwards. The pane's pages come +from docs/_site, so run build.bat first. + +A picture is refused, and the run fails, when the visible text of the page it +was taken from holds the Windows user name. + + --only only the pictures whose name (Pane, Hover, Search, Choices, + Description, Settings, Window, Toolbar) matches + --out where the pictures go (default docs/IDE/AddIns/Images) + --port the IDE's DevTools port: the first free one from n (default + 9620); the detached window's is the next free one after it, + and the IDE that builds the add-in takes the one after that + --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 every picture was written or was unchanged + 1 a picture failed: an element was not found, the page showed the user name, + the add-in did not build, or the demo 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: { + only: { type: "string" }, + out: { type: "string" }, + port: { type: "string" }, + ide: { type: "string" }, + help: { type: "boolean", short: "h", default: false }, + }, + stopAt: ["help"], + }), +); +if (values.help) printHelpAndExit(USAGE); +const only = values.only === undefined ? null : withUsageError(() => regexOption(values.only, { option: "--only" })); +const firstPort = withUsageError(() => + numberOption(values.port ?? "9620", { option: "--port", integer: true, min: 1, max: 65535 }), +); +const outDir = path.resolve(values.out ?? DEFAULT_OUT); + +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(SITE, "tB", "symbols.json"))) die(2, `no built site in ${SITE}: run build.bat first`); + +// ---------------------------------------------------------------- the demo's names + +// A place in the demo's source: the line holding `inLine`, and the column +// (1-based) `offset` characters into the first `token` in that line. +const demoLines = readFileSync(DEMO_SOURCE, "utf8").split("\n"); +function place(inLine, token, offset) { + const line = demoLines.findIndex((l) => l.includes(inLine)); + if (line < 0) die(2, `${DEMO_SOURCE} has no line with ${JSON.stringify(inLine)}`); + const at = demoLines[line].indexOf(token, demoLines[line].indexOf(inLine)); + if (at < 0) die(2, `${JSON.stringify(token)} is not in the line with ${JSON.stringify(inLine)}`); + return { line: line + 1, column: at + 1 + offset }; +} +// F1 and the hover work on any column of a name; the middle letters are safe. +const AT = { + msgBox: place('MsgBox "Total', "MsgBox", 2), + hover: place("As New Collection", "Collection", 3), + add: place('prices.Add "Widget"', ".Add", 2), + totalPrice: place("& TotalPrice(", "TotalPrice", 3), +}; + +// ---------------------------------------------------------------- what to shoot + +// Each shot is one file, named for the page's figure, and a function that +// leaves the IDE showing it and returns what to capture. Run in the order of +// the page; each brings the IDE to the state it needs and leaves it usable. +const HELP_PAGE = "/tB/Modules/Interaction/MsgBox"; + +const clamp = (r, vw, vh) => { + const x0 = Math.max(0, Math.floor(r.x)); + const y0 = Math.max(0, Math.floor(r.y)); + const x1 = Math.min(vw, Math.ceil(r.x + r.width)); + const y1 = Math.min(vh, Math.ceil(r.y + r.height)); + return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }; +}; +// The whole CSS pixels inside a rectangle: a capture of the pane's own box, with +// none of what is under its fractional edge (a few pixels of it change with the timing). +const inside = (r) => { + const x0 = Math.ceil(r.x); + const y0 = Math.ceil(r.y); + return { x: x0, y: y0, width: Math.floor(r.x + r.width) - x0, height: Math.floor(r.y + r.height) - y0 }; +}; +const grow = (r, m) => ({ x: r.x - m, y: r.y - m, width: r.width + 2 * m, height: r.height + 2 * m }); +const union = (a, b) => { + const x0 = Math.min(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const x1 = Math.max(a.x + a.width, b.x + b.width); + const y1 = Math.max(a.y + a.height, b.y + b.height); + return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }; +}; + +const QUIET_CSS = + "*,*::before,*::after{animation:none !important;transition:none !important;caret-color:transparent !important;scroll-behavior:auto !important}" + + ".monaco-editor .cursors-layer{visibility:hidden !important}"; + +// Gives the document and every shadow root in it the quiet style sheet, once each. +const quiet = (conn) => + conn.evaluate(`(() => { + const css = ${JSON.stringify(QUIET_CSS)}; + const add = (root) => { + if (root.querySelector("#tbShotQuiet")) return; + const s = document.createElement("style"); + s.id = "tbShotQuiet"; + s.textContent = css; + (root.head || root).appendChild(s); + }; + const walk = (root) => { + add(root); + for (const e of root.querySelectorAll("*")) if (e.shadowRoot) walk(e.shadowRoot); + }; + walk(document); +})()`); + +// The visible text of a page: the document's and each shadow root's, where an +// element's innerText leaves out what is not drawn. +const VISIBLE_TEXT = `(() => { + const out = []; + const take = (e) => { if (e.innerText) out.push(e.innerText); }; + const walk = (root) => { + for (const e of root.querySelectorAll("*")) { + if (e.shadowRoot) { + for (const k of e.shadowRoot.children) take(k); + walk(e.shadowRoot); + } + } + }; + take(document.body); + walk(document); + return out.join("\\n"); +})()`; + +async function main(ctx) { + const { c, origin, windowPort, user, addinLines, results } = ctx; + + // ---- reading the pane + const inPane = (body) => + c.evaluate(`(() => { + const w = toolWindowsById[${JSON.stringify(PANE)}]; + if (!w) return null; + const root = w.bodyElement.getRootNode(); + ${body} +})()`); + const frameSrc = () => inPane(`return root.querySelector("#helpPage")?.getAttribute("src") ?? null;`); + const searchValue = () => inPane(`return root.querySelector("#helpSearch")?.value ?? null;`); + const summaryShown = () => + inPane(`const s = root.querySelector("#helpSummary"); return !!s && s.style.display !== "none";`); + const settingsOpen = () => + inPane(`return root.querySelector("#helpSettings")?.getAttribute("aria-expanded") === "true";`); + const paneRect = () => + c.evaluate(`(() => { + const f = toolWindowsById[${JSON.stringify(PANE)}]?.shadowDom.host.closest(".floatingPanel"); + if (!f) return null; + const r = f.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; +})()`); + // The pane from its top to the last result row shown whole: the page under the + // list is the one an earlier F1 left, and says nothing about the list. + const listClip = async () => { + const pane = await paneRect(); + // The list scrolls, and the page's frame under it covers the rows past its end. + const { last, end, more } = await inPane(`const list = root.querySelector("#helpResults").getBoundingClientRect(); + const page = root.querySelector("#helpPage")?.getBoundingClientRect(); + const end = page && page.height && page.top > list.top ? Math.min(list.bottom, page.top) : list.bottom; + const all = [...root.querySelectorAll("#helpResults .hit")].map((r) => r.getBoundingClientRect()); + const rows = all.filter((r) => r.bottom <= end + 0.5).map((r) => r.bottom); + return { last: rows.length ? Math.max(...rows) : end, end, more: all.some((r) => r.bottom > end + 0.5) };`); + // Rows have no gap between them: with more past the view, end at the last whole one. + return inside({ ...pane, height: Math.min(last + (more ? 0 : 8), end) - pane.y }); + }; + // The floating pane covers the middle of the editor: against the window's right edge. + const paneAside = () => + c.evaluate(`(() => { + const s = toolWindowsById[${JSON.stringify(PANE)}]?.shadowDom.host.closest(".floatingPanel"); + if (!s) return; + s.style.left = (innerWidth - s.getBoundingClientRect().width - 4) + "px"; +})()`); + const paneVisibility = (v) => + c.evaluate(`(() => { + const s = toolWindowsById[${JSON.stringify(PANE)}]?.shadowDom.host.closest(".floatingPanel"); + if (s) s.style.visibility = ${JSON.stringify(v)}; +})()`); + const editorRect = () => + c.evaluate( + `(() => { const r = editor.getDomNode().getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })()`, + ); + const elementRect = (id) => + c.evaluate(`(() => { + const e = document.getElementById(${JSON.stringify(id)}); + if (!e) return null; + const r = e.getBoundingClientRect(); + return r.width && r.height ? { x: r.x, y: r.y, width: r.width, height: r.height } : null; +})()`); + + // ---- operating + async function at({ line, column }) { + await openFile(c, DEMO_FILE, { line, column }); + await setCursor(c, line, column); + await c.evaluate("editor.setScrollTop(0)"); + await sleep(300); + } + async function emptySearch() { + if (!(await searchValue())) return; + await click(c, { toolWindow: PANE, css: "#helpSearch" }); + await pressKey(c, "End"); + for (let n = (await searchValue()).length; n > 0; n--) await pressKey(c, "Backspace"); + } + async function closeSettings() { + if (await settingsOpen()) await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await waitFor(c, async () => !(await settingsOpen())); + } + // F1 on a name of the demo. + async function f1(target) { + for (let attempt = 1; ; attempt++) { + await at(target); + await pressKey(c, "F1"); + // the pane is made and shown by the first one + if (await waitFor(c, async () => (await paneRect()) && true, { timeout: 8000 })) return; + if (attempt === 3) throw new Error("F1 did not show the pane"); + } + } + // The pane's page for the name, loaded: F1 on it, then wait for the frame's + // document to be one made after the F1, complete, and the summary hidden. + async function showPage(target, pagePath) { + await closeSettings(); + await emptySearch(); + const markIt = () => frameEval(c, origin, "window.tbShotMark = true").catch(() => {}); + await markIt(); + await f1(target); + const loaded = await waitFor( + c, + async () => { + const src = await frameSrc(); + if (!src?.includes(pagePath) || (await summaryShown())) return false; + const f = await frameOf(c, origin); + if (!f || !f.url.includes(pagePath.replace(/\/$/, ""))) return false; + const ready = await frameEval(c, origin, `document.readyState === "complete" && !window.tbShotMark`).catch( + () => false, + ); + return ready === true; + }, + { timeout: 20000 }, + ); + if (!loaded) throw new Error(`the pane did not show ${pagePath}`); + await sleep(2500); // fonts and images in the frame + await paneAside(); + } + // The results list, once it has stopped changing for a second. + async function settledResults() { + let list = ""; + let since = 0; + return waitFor( + c, + async () => { + const r = await results(); + if (JSON.stringify(r) !== list) [list, since] = [JSON.stringify(r), Date.now()]; + return r.length > 0 && Date.now() - since >= 1000 && r; + }, + { timeout: 20000 }, + ); + } + + // ---- capturing + async function textOf(conn, withFrame) { + let text = await conn.evaluate(VISIBLE_TEXT); + if (withFrame) text += `\n${await frameEval(conn, withFrame, "document.body.innerText").catch(() => "")}`; + return text; + } + // A capture of `clip` (CSS pixels, whole numbers) from `conn`, refused when + // the page's visible text holds the user name. The mouse goes to the page's + // corner first, since a control under it draws its hover look, unless the + // picture is of what the mouse rests on. + async function capture(conn, name, clip, { frame, resting = false } = {}) { + const text = await textOf(conn, frame); + if (text.toLowerCase().includes(user.toLowerCase())) + throw new Error(`the page for ${name} shows the Windows user name`); + if (conn === c && !resting) await mouseAway(c); + await quiet(conn); + await sleep(300); + const params = { format: "png" }; + if (clip) params.clip = { ...clip, scale: 1 }; + const { data } = await conn.send("Page.captureScreenshot", params); + return Buffer.from(data, "base64"); + } + + // ---- the shots + const shots = []; + const shoot = (name, fn) => shots.push({ name, fn }); + + // The pane with the page F1 found, and the code beside it. + shoot("Pane", async () => { + await showPage(AT.msgBox, HELP_PAGE); + const ed = await editorRect(); + // The pane ends where the editor does, so that the DEBUG CONSOLE, whose lines + // carry the time they were written, is not in the picture. + const setHeight = (h) => + c.evaluate(`(() => { + const s = toolWindowsById[${JSON.stringify(PANE)}]?.shadowDom.host.closest(".floatingPanel"); + if (s) s.style.height = ${JSON.stringify(h)}; +})()`); + const was = await paneRect(); + try { + await setHeight(`${Math.floor(ed.y + ed.height - was.y - 4)}px`); + await sleep(500); + const pane = await paneRect(); + // From the editor's left edge, its gutter included, and from its tab row, to + // the pane's right and bottom edges. + const top = ed.y - 60; + const clip = inside({ x: ed.x, y: top, width: pane.x + pane.width - ed.x, height: pane.y + pane.height - top }); + return await capture(c, "Pane", clip, { frame: origin }); + } finally { + await setHeight(""); + } + }); + + // The IDE's own hover with the add-in's link, and a pointer resting on the name. + shoot("Hover", async () => { + await showPage(AT.msgBox, HELP_PAGE); + // tick Hover help in the settings panel, if it is not + const ticked = await inPane(`return root.querySelector("#helpHover").checked;`); + if (!ticked) { + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await waitFor(c, settingsOpen); + await click(c, { toolWindow: PANE, css: "#helpHover" }); + await sleep(300); + await closeSettings(); + } + await paneVisibility("hidden"); + try { + const link = "Help: Collection class"; + // The class's hover lists its members and is tall: with the editor scrolled + // up by two lines it fits above the DEBUG CONSOLE, whose lines carry their time. + await c.evaluate("editor.setScrollTop(44)"); + let shown = null; + for (let attempt = 0; attempt < 3 && !shown; attempt++) { + await restMouse(c, AT.hover.line, AT.hover.column); + shown = await waitFor(c, async () => (await hoverText(c))?.includes(link) && true); + } + if (!shown) throw new Error("the hover with the add-in's link did not show"); + await sleep(500); + const hover = await c.evaluate(`(() => { + const h = [...document.querySelectorAll(".monaco-hover")].find((e) => !e.classList.contains("hidden") && e.getBoundingClientRect().height > 0); + const r = h.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height }; +})()`); + // the mouse's place, as restMouse sent it last + const p = await pointOf(c, AT.hover.line, AT.hover.column); + const tip = { x: p.x + 1, y: p.y }; + // the code line the pointer rests on, from the editor's left edge + const ed = await editorRect(); + const name = { x: ed.x, y: p.top, width: tip.x + 120 - ed.x, height: p.height + 24 }; + await c.evaluate(`(() => { + const d = document.createElement("div"); + d.id = "tbShotPointer"; + d.style.cssText = "position:fixed;left:${tip.x}px;top:${tip.y}px;width:12px;height:19px;pointer-events:none;z-index:2147483647"; + d.innerHTML = ''; + document.body.appendChild(d); +})()`); + // Never as far down as the DEBUG CONSOLE, whose lines carry their time. + const area = grow(union(hover, name), 20); + // less below than around: the next code line starts 8 pixels under the box + area.height = Math.min(area.height - 14, ed.y + ed.height - area.y); + const clip = clamp(area, IDE_SIZE.width, IDE_SIZE.height); + const png = await capture(c, "Hover", clip, { resting: true }); + await c.evaluate(`document.getElementById("tbShotPointer")?.remove()`); + return png; + } finally { + await c.evaluate(`document.getElementById("tbShotPointer")?.remove()`); + await mouseAway(c); + await paneVisibility(""); + } + }); + + // The pane with a query typed and the results listed. + shoot("Search", async () => { + await showPage(AT.msgBox, HELP_PAGE); + await click(c, { toolWindow: PANE, css: "#helpSearch" }); + await typeText(c, "format"); + const items = await settledResults(); + if (!items) throw new Error("the search listed nothing"); + await paneAside(); + const png = await capture(c, "Search", await listClip(), { frame: origin }); + await emptySearch(); + return png; + }); + + // F1 on a member of a late-bound object lists the pages the member could be. + shoot("Choices", async () => { + await showPage(AT.msgBox, HELP_PAGE); + await f1(AT.add); + const items = await settledResults(); + if (!items || items.length < 2) throw new Error(`F1 on a late-bound member listed ${JSON.stringify(items)}`); + await paneAside(); + const png = await capture(c, "Choices", await listClip(), { frame: origin }); + await emptySearch(); + return png; + }); + + // F1 on a procedure of the project with a [Description]: the summary, not a page. + shoot("Description", async () => { + await showPage(AT.msgBox, HELP_PAGE); + await f1(AT.totalPrice); + const shown = await waitFor(c, summaryShown, { timeout: 20000 }); + if (!shown) throw new Error("F1 on a procedure with a description did not show the summary"); + await sleep(500); + await paneAside(); + // The summary is short, and the rest of the pane is empty: its top part. + const pane = await paneRect(); + return capture(c, "Description", inside({ ...pane, height: 170 }), { frame: origin }); + }); + + // The settings panel the gear opens, with the top of the page under it. + shoot("Settings", async () => { + await showPage(AT.msgBox, HELP_PAGE); + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + if (!(await waitFor(c, settingsOpen))) throw new Error("the gear did not open the settings panel"); + await sleep(500); + await paneAside(); + const pane = await paneRect(); + // 150 CSS pixels ends between the page's heading and its first paragraph. + const png = await capture(c, "Settings", inside({ ...pane, height: 150 }), { frame: origin }); + await closeSettings(); + return png; + }); + + // The toolbar's Help button. + shoot("Toolbar", async () => { + await mouseAway(c); + const b = await elementRect("addinButton-tbDocsHelp"); + if (!b) throw new Error("the toolbar has no Help button"); + const clip = clamp( + // The buttons before it too, so that a reader can find it on the toolbar. + { x: b.x - 260, y: b.y - 10, width: b.width + 284, height: b.height + 20 }, + IDE_SIZE.width, + IDE_SIZE.height, + ); + return capture(c, "Toolbar", clip); + }); + + // The detached window with a page. Last: the pane is gone afterwards. + shoot("Window", async () => { + await showPage(AT.msgBox, HELP_PAGE); + const mark = await consoleMark(c); + await click(c, { toolWindow: PANE, css: "#helpDetach" }); + if (!(await waitFor(c, async () => (await addinLines(mark)).includes("detached"), { timeout: 30000 }))) { + throw new Error(`the add-in did not detach: ${JSON.stringify(await addinLines(mark))}`); + } + let win = null; + try { + const attached = await waitFor( + c, + async () => { + try { + win = await attach(windowPort, "", { timeout: 30000 }); + return true; + } catch { + return false; + } + }, + { timeout: 30000 }, + ); + if (!attached) throw new Error(`no page on the window's DevTools port ${windowPort}`); + await win.send("Emulation.setFocusEmulationEnabled", { enabled: true }); + await win.send("Emulation.setDeviceMetricsOverride", { ...WINDOW_SIZE, deviceScaleFactor: SCALE, mobile: false }); + if ( + !(await waitFor(c, async () => (await addinLines(mark)).some((l) => l.startsWith("window page ")), { + timeout: 30000, + })) + ) { + throw new Error("the window's page did not load"); + } + await sleep(3000); // fonts and images in the frame + return await capture(win, "Window", null, { frame: origin }); + } finally { + win?.close(); + } + }); + + return shots; +} + +// ---------------------------------------------------------------- run + +mkdirSync(outDir, { recursive: true }); +let port; +let windowPort; +let buildPort; +try { + [port, windowPort, buildPort] = await claimPorts(3, { from: firstPort }); +} catch (e) { + die(2, e.message); +} + +const work = path.join(tmpdir(), "tbshoot-help", 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)]); +// The add-in's saved settings are the user's; a run starts from none. +deleteSettings([SETTINGS]); + +const lane = new Lane({ name: "shoot", port, work, ide, show: false }); +let server = null; + +// 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); +}); + +// Writes a picture when its bytes differ from the file's; says which. +function keep(name, png) { + const file = path.join(outDir, `Help_${name}.png`); + let state = "new"; + if (existsSync(file)) state = readFileSync(file).equals(png) ? "unchanged" : "updated"; + if (state !== "unchanged") writeFileSync(file, png); + const size = `${png.readUInt32BE(16)}x${png.readUInt32BE(20)} px, ${png.length} bytes`; + console.log(`Help_${name}.png: ${state} (${size})`); +} + +// 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"; +const t0 = Date.now(); +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}`); + // The add-in as committed, without the help archive, so that the pane's frame + // loads the site served here (as the help lane does). + const src = path.join(work, "help-src"); + cpSync(ADDIN, src, { + recursive: true, + filter: (f) => ![path.join(ADDIN, "Build"), path.join(ADDIN, "Resources", "HELP")].includes(f), + }); + // On a port of its own: the building IDE's WebView2 can keep listening on its + // port for longer than launchIde waits (ten seconds) after the IDE ends. + await lane.addAddin(src, { show: false, port: buildPort }); + step = "open"; + console.log(`opening ${path.relative(REPO_ROOT, DEMO)}`); + const c = await lane.open(DEMO, { + env: { + TB_DOCS_HELP_SITE: origin, + TB_DOCS_HELP_WINDOW_PORT: String(windowPort), + TB_DOCS_HELP_REAL_TITLEBAR: "0", + }, + }); + step = "shoot"; + const addinLines = (mark) => linesSince(c, mark, { prefix: "[tbDocsHelp] " }); + if ( + !(await waitFor(c, async () => (await addinLines(null)).some((l) => l.startsWith("loaded")), { timeout: 30000 })) + ) { + throw new Error("the add-in never printed its loaded line"); + } + await openFile(c, DEMO_FILE, { line: 1, column: 1 }); + // the dark theme, as the documentation shows it + const isDark = () => + c.evaluate(`(() => { + const p = document.createElement("div"); + p.style.background = "var(--themeGeneralPanelBackColor)"; + document.body.appendChild(p); + const [r, g, b] = getComputedStyle(p).backgroundColor.match(/\\d+/g).map(Number); + p.remove(); + return 0.299 * r + 0.587 * g + 0.114 * b < 128; +})()`); + if (!(await isDark())) { + await c.evaluate('executeIdeCommand("tbTheme_SwitchToDarkMode")'); + if (!(await waitFor(c, isDark, { timeout: 10000 }))) throw new Error("the IDE did not switch to the dark theme"); + } + await c.send("Emulation.setDeviceMetricsOverride", { ...IDE_SIZE, deviceScaleFactor: SCALE, mobile: false }); + await sleep(1500); + + const ctx = { + c, + origin, + windowPort, + user: userInfo().username, + addinLines, + results: async () => (await listViewItems(c, { toolWindow: PANE, css: "#helpResults" })).map((i) => i.text), + }; + const shots = await main(ctx); + for (const { name, fn } of shots) { + if (only && !only.test(name)) continue; + try { + keep(name, await fn()); + } catch (e) { + console.error(`Help_${name}.png: FAILED: ${e.message}`); + failed = 1; + } + } + console.log(`${((Date.now() - t0) / 1000).toFixed(1)} s`); +} 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)) || + step === "shoot"; + 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/test/addin/help.test.mjs b/test/addin/help.test.mjs index 84a8171c5..9576ce34d 100644 --- a/test/addin/help.test.mjs +++ b/test/addin/help.test.mjs @@ -306,7 +306,12 @@ scenario("the help add-in: F1 and the help pane", (lane) => { 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 m = /^loaded, index (\d+) entries, packages ([^,]*), test switch on$/.exec(loaded); + assert.ok(m, `the loaded line: ${loaded}`); + assert.equal(Number(m[1]), entries); + // The packages the host project references, and the ones those reference, + // in the order the IDE lists them. + assert.deepEqual(m[2].split(" ").sort(), ["VB", "VBA", "VBRUN", "tbIDE"], loaded); const pane = await toolWindow(c, PANE); assert.equal(pane?.title, "TWINBASIC HELP"); assert.equal(pane.visible, false); @@ -456,7 +461,9 @@ scenario("the help add-in: F1 and the help pane", (lane) => { if (JSON.stringify(r) !== list) [list, since] = [JSON.stringify(r), Date.now()]; return r.length > 0 && Date.now() - since >= 1000 && r; }); - assert.equal(items?.length, 12, JSON.stringify(items)); + // The index has 12 Add pages; those of the packages the project does not + // reference are not offered. + assert.equal(items?.length, 6, 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"); @@ -632,6 +639,108 @@ scenario("the help add-in: F1 and the help pane", (lane) => { // Hover help: links in the IDE's own hover under the mouse, while the pane's // box is ticked. const hoverBox = () => inPane(c, `return root.querySelector("#helpHover")?.checked ?? null;`); + + // The settings panel, in the pane (inPane) and in the window's page (its + // `root` is the document): what the gear says, and whether the panel and the + // shade under it show. + const SETTINGS_STATE = `const g = root.querySelector("#helpSettings"); + const p = root.querySelector("#helpSettingsPanel"); + const s = root.querySelector("#helpSettingsShade"); + if (!g || !p || !s) return null; + return { + expanded: g.getAttribute("aria-expanded"), + panel: getComputedStyle(p).display !== "none", + shade: getComputedStyle(s).display !== "none", + };`; + const SETTINGS_OPEN = { expanded: "true", panel: true, shade: true }; + const SETTINGS_CLOSED = { expanded: "false", panel: false, shade: false }; + const paneSettings = () => inPane(c, SETTINGS_STATE); + const windowSettings = () => win.evaluate(`(() => { const root = document; ${SETTINGS_STATE} })()`); + // Waits for a view's panel to be in `want`, which the add-in sets a moment + // after the click, and fails with what it was. + async function settingsBecome(read, want, what) { + await waitFor(c, async () => JSON.stringify(await read()) === JSON.stringify(want)); + assert.deepEqual(await read(), want, what); + } + // The look the panel has to have, in the pane (`root`) or in the window's + // page: the theme's panel colours and border, read from elements the theme's + // own properties are applied to. + const PANEL_LOOK = `(() => { + const look = (e) => { const s = getComputedStyle(e); return { background: s.backgroundColor, color: s.color, border: s.borderTopColor, borderStyle: s.borderTopStyle, borderWidth: s.borderTopWidth }; }; + const probe = document.createElement("div"); + probe.style.cssText = "background: var(--themeGeneralPanelBackColor); color: var(--themeToolWindowBodyForeColor); border: var(--themePropertySheetValueBorderColor)"; + (root.querySelector("#helpWrap") ?? root.body).appendChild(probe); + const want = look(probe); + probe.remove(); + return { want, got: look(root.querySelector("#helpSettingsPanel")) }; + })()`; + const paneLook = () => inPane(c, `return ${PANEL_LOOK};`); + const windowLook = () => win.evaluate(`(() => { const root = document; return ${PANEL_LOOK}; })()`); + // Where the panel, the gear and the shade are, and what a click at the + // panel's centre and at the page's would reach: the panel's own element, or + // the shade. + const SETTINGS_PLACE = `const box = (e) => { const r = e.getBoundingClientRect(); return [r.left, r.top, r.right, r.bottom].map(Math.round); }; + const g = root.querySelector("#helpSettings"); + const p = root.querySelector("#helpSettingsPanel"); + const page = root.querySelector("#helpPage").getBoundingClientRect(); + const hitAt = (x, y) => { const e = root.elementFromPoint(x, y); return e?.closest("#helpSettingsPanel") ? "panel" : e?.id ?? null; }; + const r = p.getBoundingClientRect(); + return { + gear: box(g), + panel: box(p), + wrap: box(root.querySelector("#helpWrap")), + row: p.contains(root.querySelector("#helpHoverLabel")), + panelHit: hitAt(r.left + r.width / 2, r.top + r.height / 2), + pageHit: hitAt(page.left + page.width / 2, page.top + page.height / 2), + };`; + const panePlace = () => inPane(c, SETTINGS_PLACE); + const windowPlace = () => win.evaluate(`(() => { const root = document; ${SETTINGS_PLACE} })()`); + // What a view's panel has to look like open: right-aligned with the gear + // and under it, inside the view, on top of everything else; and the shade + // over the page. + function assertPlace(place, what) { + const [gl, gt, gr, gb] = place.gear; + const [pl, pt, pr, pb] = place.panel; + const [wl, wt, wr, wb] = place.wrap; + assert.equal(pr, gr, `${what}: the panel is not right-aligned with the gear`); + assert.ok(pt >= gb, `${what}: the panel is not under the gear: ${JSON.stringify(place)}`); + assert.ok( + pl >= wl && pr <= wr && pb <= wb && gt >= wt && gl >= wl, + `${what}: the panel is cut off: ${JSON.stringify(place)}`, + ); + assert.ok(place.row, `${what}: the Hover help row is not in the panel`); + assert.equal(place.panelHit, "panel", `${what}: something is over the panel`); + assert.equal(place.pageHit, "helpSettingsShade", `${what}: the shade is not over the page`); + } + // Ticks or unticks one of the pane's boxes, which are in the settings + // panel: opens the panel, clicks the box, checks the panel is still open, + // and closes it with the gear. + async function clickSettingBox(css) { + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await settingsBecome(paneSettings, SETTINGS_OPEN, "the gear did not open the panel"); + await click(c, { toolWindow: PANE, css }); + assert.deepEqual(await paneSettings(), SETTINGS_OPEN, "the panel closed when the box was clicked"); + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await settingsBecome(paneSettings, SETTINGS_CLOSED, "the gear did not close the panel"); + } + const clickHoverBox = () => clickSettingBox("#helpHover"); + const clickAllBox = () => clickSettingBox("#helpAllPackages"); + // The All packages box in the pane, and in the window's page. + const allBox = () => inPane(c, `return root.querySelector("#helpAllPackages")?.checked ?? null;`); + const windowAllBox = () => win.evaluate(`document.getElementById("helpAllPackages").checked`); + // Waits for the results to be `want`, a function of the rows, and fails with the rows. + async function resultsBecome(want, what) { + const rows = await waitFor(c, async (c) => { + const r = await results(c); + return want(r) && r; + }); + assert.ok(rows, `${what}: ${JSON.stringify(await results(c))}`); + return rows; + } + const NO_MATCHES = (r) => r.join() === "No matches"; + // The eight Waynes... controls are in CustomControls, and no other name has + // "Waynes" in it. + const HAS_WAYNES = (r) => r.some((t) => t.startsWith("CustomControlsPackage.Waynes")); // A class's hover lists its members, and is taller than the room below it. const COLLECTION_LINK = "Help: Collection class (VBA)"; const LEN_LINK = "Help: Len function (VBA.Strings)"; @@ -662,6 +771,95 @@ scenario("the help add-in: F1 and the help pane", (lane) => { } } + // The settings gear at the bar's right end opens a panel of rows under it. + test("the bar ends in a gear, drawn in the text colour, as tall and bordered as the other buttons", async () => { + const bar = await inPane( + c, + `const el = (id) => root.querySelector("#" + id); + const g = el("helpSettings"), d = el("helpDetach"); + const gs = getComputedStyle(g), ds = getComputedStyle(d); + return { + order: [...el("helpBar").children].map((e) => e.id), + title: g.title, + label: g.getAttribute("aria-label"), + expanded: g.getAttribute("aria-expanded"), + shapes: g.querySelectorAll("svg circle, svg path").length > 0, + stroke: getComputedStyle(g.querySelector("svg path")).stroke === gs.color, + sameHeight: g.getBoundingClientRect().height === d.getBoundingClientRect().height, + sameBorder: gs.borderTopColor === ds.borderTopColor && gs.borderTopWidth === ds.borderTopWidth, + sameFont: gs.fontSize === ds.fontSize && gs.color === ds.color, + };`, + ); + assert.deepEqual(bar, { + order: ["helpSearch", "helpBrowser", "helpDetach", "helpSettingsWrap"], + title: "Settings", + label: "Settings", + expanded: "false", + shapes: true, + stroke: true, + sameHeight: true, + sameBorder: true, + sameFont: true, + }); + assert.deepEqual(await paneSettings(), SETTINGS_CLOSED); + }); + + test("the gear opens the settings panel under it, over the pane, and sets aria-expanded", async () => { + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await settingsBecome(paneSettings, SETTINGS_OPEN, "the gear did not open the panel"); + assertPlace(await panePlace(), "the pane"); + const { want, got } = await paneLook(); + assert.deepEqual(got, want, "the panel is not in the theme's colours"); + assert.notEqual(got.borderStyle, "none"); + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await settingsBecome(paneSettings, SETTINGS_CLOSED, "the gear did not close the panel"); + }); + + // A click outside the panel closes it and does nothing else: not on another + // control, which is under the shade, and not over the page's frame. + async function clickOutsidePane(css) { + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await settingsBecome(paneSettings, SETTINGS_OPEN, "the gear did not open the panel"); + const mark = await consoleMark(c); + const r = await elementRect(c, { toolWindow: PANE, css }); + await clickAt(c, r.x + r.width / 2, r.y + r.height / 2); + await settingsBecome(paneSettings, SETTINGS_CLOSED, `a click on ${css} did not close the panel`); + assert.deepEqual(await openedUrls(c, { since: mark }), [], `the click on ${css} reached it`); + } + + test("a click on the shade closes the panel, over another control or over the page", async () => { + await clickOutsidePane("#helpBrowser"); + await clickOutsidePane("#helpPage"); + }); + + // The live site is on another site than the IDE's page, and a press over a + // frame there goes to the frame's own process. + test("a click over a page on another site closes the panel", async () => { + const was = await frameSrc(c); + const other = `http://127.0.0.1:${server.port}/index.html`; + await inPane(c, `root.querySelector("#helpPage").src = ${JSON.stringify(other)}; return null;`); + assert.ok(await waitFor(c, async (c) => !(await frameOf(c, pages))), "the frame is still in the page's tree"); + try { + await clickOutsidePane("#helpPage"); + } finally { + await inPane(c, `root.querySelector("#helpPage").src = ${JSON.stringify(was)}; return null;`); + await waitFor(c, async (c) => !!(await frameOf(c, pages))); + } + }); + + test("Esc closes the panel from inside it, and the gear has the focus", async () => { + await click(c, { toolWindow: PANE, css: "#helpSettings" }); + await settingsBecome(paneSettings, SETTINGS_OPEN, "the gear did not open the panel"); + await inPane(c, `root.querySelector("#helpHover").focus(); return null;`); + assert.equal(await focusedId(c), "helpHover"); + await pressKey(c, "Escape"); + await settingsBecome(paneSettings, SETTINGS_CLOSED, "Esc did not close the panel"); + assert.ok( + await waitFor(c, async (c) => (await focusedId(c)) === "helpSettings"), + `the focus: ${await focusedId(c)}`, + ); + }); + test("hover help is off until its box is ticked", async () => { assert.equal(await hoverBox(), false); const mark = await consoleMark(c); @@ -697,7 +895,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { // The link is the hover's first line, where a long hover shows it without // being scrolled. test("with the box ticked, the mouse hover has the link first, and a click shows the page", async () => { - await click(c, { toolWindow: PANE, css: "#helpHover" }); + await clickHoverBox(); assert.equal(await hoverBox(), true); const mark = await consoleMark(c); const t = await mouseHover(3, 22, COLLECTION_LINK); @@ -722,11 +920,25 @@ scenario("the help add-in: F1 and the help pane", (lane) => { assert.deepEqual(await addinLines(c, mark), []); }); - test("after a compiler restart the box is still ticked and the mouse hover has the link once", async () => { + // The All packages box is ticked before the restart and unticked after it. + test("after a compiler restart the boxes are still ticked and the mouse hover has the link once", async () => { + await clickAllBox(); + assert.equal(await allBox(), true); const mark = await consoleMark(c); await lane.restartCompiler(); pages = await pagesOrigin(mark); assert.ok(await waitFor(c, async () => (await hoverBox()) === true), "the box is not ticked after the restart"); + assert.ok(await waitFor(c, async () => (await allBox()) === true), "All packages is not ticked after the restart"); + // The new instance offered every package from the start. + await click(c, "addinButton-tbDocsHelp"); + assert.ok(await waitFor(c, async (c) => (await toolWindow(c, PANE))?.visible), "the pane is not showing"); + await emptySearch(c); + await typeText(c, "Waynes"); + await resultsBecome(HAS_WAYNES, "the search after the restart with All packages"); + await clickAllBox(); + assert.equal(await allBox(), false); + await resultsBecome(NO_MATCHES, "unticking All packages with a search showing"); + await emptySearch(c); await openFile(c, "/HelpHost/Sources/Cases.twin", { line: 9, column: 9 }); const t = await mouseHover(7, 21, LEN_LINK); assert.ok(t, `the mouse hover: ${JSON.stringify(await hoverText(c))}`); @@ -735,7 +947,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { }); test("unticking the box turns hover help off, and the hover is still drawn above the dock", async () => { - await click(c, { toolWindow: PANE, css: "#helpHover" }); + await clickHoverBox(); assert.equal(await hoverBox(), false); assert.ok( await c.evaluate("!!document.getElementById('tbDocsHoverStay')"), @@ -746,6 +958,78 @@ scenario("the help add-in: F1 and the help pane", (lane) => { await mouseAway(c); }); + // ------------------------------------------------------------ the packages offered + + // The host references tbIDE, VB, VBRUN and VBA. The Waynes controls are in + // CustomControls, CefBrowser in CEF and WebView2 in WebView2, which it does + // not; VBRUN has a constant named for each of the last two. + async function searchFor(text) { + await emptySearch(c); + await typeText(c, text); + } + + test("a search offers the names of the referenced packages and the language's, not another package's", async () => { + assert.equal(await allBox(), false); + for (const [text, row] of [ + ["Collection", "VBA.Collection"], // VBA + ["CheckBox", "VB.CheckBox"], // VB + ["ToolWindows", "tbIDE.ToolWindows"], // tbIDE + ["Dim", "Dim"], // the language itself + ]) { + await searchFor(text); + const rows = await resultsBecome((r) => r.some((t) => t.startsWith(row)), `a search for ${text}`); + assert.ok(!NO_MATCHES(rows)); + } + await searchFor("Waynes"); + await resultsBecome(NO_MATCHES, "a search for Waynes"); + // Only the VBRUN constant that names the control matches. + await searchFor("CefBrowser"); + let rows = await resultsBecome((r) => r.some((t) => t.includes("vbCefBrowser")), "a search for CefBrowser"); + assert.ok(!rows.some((t) => t.startsWith("cefPackage.")), JSON.stringify(rows)); + await searchFor("WebView2"); + rows = await resultsBecome((r) => r.some((t) => t.includes("vbWebView2")), "a search for WebView2"); + assert.ok(!rows.some((t) => t.startsWith("WebView2Package.")), JSON.stringify(rows)); + }); + + test("the All packages box offers every package at once, and unticking it takes them away again", async () => { + await searchFor("Waynes"); + await resultsBecome(NO_MATCHES, "a search for Waynes"); + await clickAllBox(); + assert.equal(await allBox(), true); + // The search runs again with no key pressed. + await resultsBecome(HAS_WAYNES, "ticking All packages"); + assert.equal(await searchValue(c), "Waynes"); + await searchFor("WebView2"); + await resultsBecome((r) => r.some((t) => t.startsWith("WebView2Package.WebView2")), "a search for WebView2"); + await searchFor("CefBrowser"); + await resultsBecome((r) => r.some((t) => t.startsWith("cefPackage.CefBrowser")), "a search for CefBrowser"); + await clickAllBox(); + assert.equal(await allBox(), false); + await resultsBecome((r) => !r.some((t) => t.startsWith("cefPackage.")), "unticking All packages"); + await searchFor("Waynes"); + await resultsBecome(NO_MATCHES, "a search for Waynes again"); + }); + + test("F1 on a name only an unreferenced package documents says so, and with All packages shows its page", async () => { + const was = await frameSrc(c); + await at(c, 39, 10); + await pressKey(c, "F1"); + const text = "No help for 'CefBrowser' in the packages this project references"; + 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, "the page changed"); + await clickAllBox(); + try { + await f1Shows("/tB/Packages/CEF/CefBrowser/"); + } finally { + if (await allBox()) await clickAllBox(); + } + assert.equal(await allBox(), false); + // The window starts with the search box as the pane leaves it: empty. + await emptySearch(c); + }); + // ------------------------------------------------------------ the detached window let win; // the DevTools connection to the window's page @@ -777,6 +1061,11 @@ scenario("the help add-in: F1 and the help pane", (lane) => { (await addinLines(c, mark)).find((l) => l.startsWith("window page "))?.slice("window page ".length) ?? null; const windowRows = () => win.evaluate(`[...document.querySelectorAll("#helpResults .hit .label")].map((e) => e.textContent)`); + // The rows of the window's results, or its "No matches". + const windowResultText = () => + win.evaluate( + `[...document.querySelectorAll("#helpResults .hit .label, #helpResults .none")].map((e) => e.textContent)`, + ); const windowBox = () => win.evaluate(`document.getElementById("helpHover").checked`); // A click on an element of the window's page. async function clickInWindow(css) { @@ -851,7 +1140,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { ); }); - test("the window's title bar is one row, with the title and four buttons, and Windows draws no caption", async () => { + test("the window's title bar is one row, with the title and five buttons, and Windows draws no caption", async () => { const bar = await win.evaluate(`(() => { const el = (id) => document.getElementById(id); const bar = el("helpTitleBar").getBoundingClientRect(); @@ -868,7 +1157,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { height: bar.height, below: el("helpBar").getBoundingClientRect().top, title: el("helpTitle").textContent, - inRow: ["helpGrip", "helpTitle", "helpAttach", "helpMinimize", "helpMaximize", "helpClose"].map(inRow), + inRow: ["helpGrip", "helpTitle", "helpAttach", "helpSettings", "helpMinimize", "helpMaximize", "helpClose"].map(inRow), rects, attachIn: el("helpAttach").parentElement.id, attachWord: getComputedStyle(el("helpAttach").querySelector(".label")).display, @@ -884,7 +1173,7 @@ scenario("the help add-in: F1 and the help pane", (lane) => { top: 0, below: height, title: "TWINBASIC HELP", - inRow: [true, true, true, true, true, true], + inRow: [true, true, true, true, true, true, true], attachIn: "helpTitleBar", attachWord: "none", }, @@ -1046,14 +1335,123 @@ scenario("the help add-in: F1 and the help pane", (lane) => { assert.equal((await toolWindow(c, PANE))?.visible, false); }); - test("Hover help can be ticked in the window, and the pane's box follows", async () => { + // The window's gear is in its title bar, and its panel is the window's own: + // opening it there does not open the pane's. + test("the gear in the window's title bar opens the settings panel under it, and sets aria-expanded", async () => { + assert.deepEqual(await windowSettings(), SETTINGS_CLOSED); + const gear = await win.evaluate(`(() => { + const g = document.getElementById("helpSettings"); + return { + in: g.parentElement.parentElement.id, + title: g.title, + label: g.getAttribute("aria-label"), + shapes: g.querySelectorAll("svg circle, svg path").length > 0, + }; + })()`); + assert.deepEqual(gear, { in: "helpTitleBar", title: "Settings", label: "Settings", shapes: true }); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_OPEN, "the gear did not open the panel"); + assertPlace(await windowPlace(), "the window"); + const { want, got } = await windowLook(); + assert.deepEqual(got, want, "the panel is not in the theme's colours"); + assert.notEqual(got.borderStyle, "none"); + assert.deepEqual(await paneSettings(), SETTINGS_CLOSED, "the pane's panel opened"); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_CLOSED, "the gear did not close the panel"); + }); + + // The window's page is on no site of its own, so its frame is always in a + // process of its own, and a press over the frame never reaches the shade: + // the page's blur closes the panel then. + async function clickOutsideWindow(css) { + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_OPEN, "the gear did not open the panel"); + const mark = await consoleMark(c); + await clickInWindow(css); + await settingsBecome(windowSettings, SETTINGS_CLOSED, `a click on ${css} did not close the panel`); + assert.deepEqual(await openedUrls(c, { since: mark }), [], `the click on ${css} reached it`); + } + + test("a click on the shade closes the window's panel, over another control or over the page", async () => { + await clickOutsideWindow("#helpBrowser"); + await clickOutsideWindow("#helpPage"); + }); + + test("Esc closes the window's panel from inside it, and the gear has the focus", async () => { + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_OPEN, "the gear did not open the panel"); + await win.evaluate(`document.getElementById("helpHover").focus()`); + await pressKey(win, "Escape"); + await settingsBecome(windowSettings, SETTINGS_CLOSED, "Esc did not close the panel"); + assert.equal(await win.evaluate(`document.activeElement?.id`), "helpSettings"); + }); + + test("Hover help can be ticked in the window, which leaves the panel open, and the pane's box follows", async () => { assert.equal(await hoverBox(), false); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_OPEN, "the gear did not open the panel"); await clickInWindow("#helpHover"); assert.ok(await waitFor(c, async () => (await hoverBox()) === true), "the pane's box is not ticked"); assert.equal(await windowBox(), true); + assert.deepEqual(await windowSettings(), SETTINGS_OPEN, "ticking the box closed the panel"); await clickInWindow("#helpHover"); assert.ok(await waitFor(c, async () => (await hoverBox()) === false), "the pane's box is still ticked"); assert.equal(await windowBox(), false); + assert.deepEqual(await windowSettings(), SETTINGS_OPEN, "unticking the box closed the panel"); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_CLOSED, "the gear did not close the panel"); + }); + + // Both ways: a tick in the window reaches the pane's box, and the pane's box, + // clicked as the page does though the pane is hidden, reaches the window's. + test("All packages can be ticked in the window or the pane, and the other follows", async () => { + assert.equal(await allBox(), false); + assert.equal(await windowAllBox(), false); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_OPEN, "the gear did not open the panel"); + await clickInWindow("#helpAllPackages"); + assert.ok(await waitFor(c, async () => (await allBox()) === true), "the pane's box is not ticked"); + assert.equal(await windowAllBox(), true); + assert.deepEqual(await windowSettings(), SETTINGS_OPEN, "ticking the box closed the panel"); + await clickInWindow("#helpAllPackages"); + assert.ok(await waitFor(c, async () => (await allBox()) === false), "the pane's box is still ticked"); + assert.equal(await windowAllBox(), false); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_CLOSED, "the gear did not close the panel"); + + await inPane(c, `root.querySelector("#helpAllPackages").click(); return null;`); + assert.ok(await waitFor(c, () => windowAllBox()), "the window's box is not ticked"); + await inPane(c, `root.querySelector("#helpAllPackages").click(); return null;`); + assert.ok(await waitFor(c, async () => (await windowAllBox()) === false), "the window's box is still ticked"); + assert.equal(await allBox(), false); + }); + + test("the window's results follow the All packages box", async () => { + await win.evaluate(`document.getElementById("helpSearch").focus()`); + await win.evaluate(`document.getElementById("helpSearch").value = ""`); + await typeText(win, "Waynes"); + assert.ok( + await waitFor(c, async () => (await windowResultText()).join() === "No matches"), + JSON.stringify(await windowResultText()), + ); + await clickInWindow("#helpSettings"); + await clickInWindow("#helpAllPackages"); + assert.ok( + await waitFor(c, async () => HAS_WAYNES(await windowResultText())), + JSON.stringify(await windowResultText()), + ); + await clickInWindow("#helpAllPackages"); + assert.ok( + await waitFor(c, async () => (await windowResultText()).join() === "No matches"), + JSON.stringify(await windowResultText()), + ); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_CLOSED, "the gear did not close the panel"); + // Back to what the later cases expect in the box. + await win.evaluate(`document.getElementById("helpSearch").focus()`); + await win.evaluate(`document.getElementById("helpSearch").value = ""`); + await typeText(win, "msgbo"); + assert.ok(await waitFor(c, async () => (await windowResultText())[0] === "Interaction.MsgBox")); }); test("a name with no page shows its summary in the window, and Open in browser is disabled there", async () => { @@ -1080,6 +1478,8 @@ scenario("the help add-in: F1 and the help pane", (lane) => { c.evaluate( `executeIdeCommand(${JSON.stringify(group === "dark" ? "tbTheme_SwitchToDarkMode" : "tbTheme_SwitchToLightMode")})`, ); + const panelBefore = await windowLook(); + assert.deepEqual(panelBefore.got, panelBefore.want, "the panel is not in the theme's colours"); try { await switchTo(other); assert.equal(await themeGroup(c), other, "the IDE's theme did not change"); @@ -1089,6 +1489,10 @@ scenario("the help add-in: F1 and the help pane", (lane) => { ); assert.ok(sameAsIde, "the window's properties are not the new theme's"); assert.equal(await win.evaluate(`document.documentElement.getAttribute("data-group")`), other); + // The panel follows the theme: the other theme's colours, not the old. + const panelAfter = await windowLook(); + assert.deepEqual(panelAfter.got, panelAfter.want, "the panel is not in the new theme's colours"); + assert.notEqual(panelAfter.got.background, panelBefore.got.background, "the panel kept the old background"); } finally { await switchTo(was); } @@ -1262,8 +1666,14 @@ scenario("the help add-in: F1 and the help pane", (lane) => { attachIn: "helpBar", attachWord: true, searchTop: 0, - order: ["helpSearch", "helpBrowser", "helpAttach", "helpHoverLabel"], + order: ["helpSearch", "helpBrowser", "helpAttach", "helpSettingsWrap"], }); + // The gear is the bar's, and opens the panel under it, as it does in the title bar. + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_OPEN, "the gear did not open the panel"); + assertPlace(await windowPlace(), "the native window"); + await clickInWindow("#helpSettings"); + await settingsBecome(windowSettings, SETTINGS_CLOSED, "the gear did not close the panel"); // The control fills the client area with the Windows title bar too, in the // window's own size and maximized (the page has no button to press). await assertGeometry("native, normal"); diff --git a/test/addin/helpdemo/Settings b/test/addin/helpdemo/Settings new file mode 100644 index 000000000..9e4b59d4a --- /dev/null +++ b/test/addin/helpdemo/Settings @@ -0,0 +1,61 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "Inventory", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "The project scripts/shoot_help_addin.mjs opens to take the help add-in's screenshots in. Its code is what the screenshots show, so it reads like a small program of a reader's own, and the tool finds each name it presses F1 on in the source rather than by line. scripts/lib/tb-lane.mjs stages a copy, with project.id and project.buildPath rewritten for the lane.", + "project.exportPathIsV2": true, + "project.id": "{B7A1C3E2-5D4F-4A86-9C21-3E8F0A6D7B15}", + "project.name": "Inventory", + "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/helpdemo/Sources/Inventory.twin b/test/addin/helpdemo/Sources/Inventory.twin new file mode 100644 index 000000000..f452efc79 --- /dev/null +++ b/test/addin/helpdemo/Sources/Inventory.twin @@ -0,0 +1,31 @@ +Module Inventory + Public Sub Main() + Dim names As New Collection + names.Add "Widget" + names.Add "Sprocket" + names.Add "Gasket" + + Dim prices As Object + Set prices = CreateObject("Scripting.Dictionary") + prices.Add "Widget", 4.5 + prices.Add "Sprocket", 12 + prices.Add "Gasket", 0.75 + + Dim item As Variant + For Each item In names + Debug.Print item, prices(item) + Next + + MsgBox "Total: " & TotalPrice(prices), vbInformation + End Sub + + [Description("Adds up the prices held in a Dictionary.")] + Public Function TotalPrice(ByVal prices As Object) As Double + Dim key As Variant + Dim total As Double + For Each key In prices.Keys + total += prices(key) + Next + Return total + End Function +End Module diff --git a/test/addin/helphost/Sources/Cases.twin b/test/addin/helphost/Sources/Cases.twin index 23d1640dc..e00d5d7b4 100644 --- a/test/addin/helphost/Sources/Cases.twin +++ b/test/addin/helphost/Sources/Cases.twin @@ -35,4 +35,6 @@ Module Cases Dim g As New Gadget Debug.Print Plain(), g Is Nothing End Sub + + ' CefBrowser is in a package this project does not reference. End Module diff --git a/test/addin/lanes.mjs b/test/addin/lanes.mjs index 125ac6ecc..2b31e145c 100644 --- a/test/addin/lanes.mjs +++ b/test/addin/lanes.mjs @@ -21,9 +21,11 @@ export default [ // Sample 15 saves all four of its option boxes whenever one is clicked. { file: "sample15.test.mjs", settings: ["GlobalSearchAddIn"] }, // Stage 2 probes. Each finds what it needs in probes/: an add-in it - // builds (keys, panes, appdata, arch, reload, entry, env) or a project it opens - // (symbols); ideserver needs neither. env's add-in prints the harness's - // environment variable and saves nothing. + // builds (keys, panes, appdata, arch, reload, entry, env, references) or a + // project it opens (symbols); ideserver needs neither. env's add-in prints the + // harness's environment variable and references' lists the packages a project + // references (the hosts it opens are in probes/references-*); neither saves + // anything. { file: "keys.test.mjs" }, { file: "panes.test.mjs" }, { file: "symbols.test.mjs" }, @@ -33,6 +35,10 @@ export default [ { file: "reload.test.mjs" }, { file: "entry.test.mjs" }, { file: "env.test.mjs" }, + { file: "references.test.mjs" }, + // P21: one IDE per case reading the Parent of the file system's root, the + // add-in told the case by an environment variable; some cases crash the compiler. + { file: "parent.test.mjs" }, // Hover help's two routes: widgets builds its add-in and saves nothing. { file: "widgets.test.mjs" }, // Detaching the help pane: a Form with a WebView2, shown by an add-in. Saves nothing. diff --git a/test/addin/parent.test.mjs b/test/addin/parent.test.mjs new file mode 100644 index 000000000..b23daee67 --- /dev/null +++ b/test/addin/parent.test.mjs @@ -0,0 +1,140 @@ +// P21 in WIP.HelpAddin.md, measured: what reading the Parent of the virtual +// file system's root folder (Host.FileSystem.RootFolder, twinbasic:/) does. The +// documentation (FileSystemItem.md, Folder.md) says it is Nothing, and an +// add-in that tested `item.Parent Is Nothing` on the root crashed the compiler. +// +// The ParentProbe add-in (probes/parent) runs one case when the project loads, +// the one the environment variable TB_P21_CASE names (the harness gives an IDE +// its environment, and the add-in reads it with Environ$, P10). Each case gets +// an IDE of its own, because a crash ends the compiler's process, the add-in's +// too. The add-in prints "[P21] " before and after each step, so +// the last line printed shows the step the compiler died in. The IDE restarts a +// crashed compiler three times, and the add-in runs again in each, so the lines +// are read per run of the add-in. +// +// a Dim o As Object = RootFolder.Parent, then "read" +// b (a), then o Is Nothing +// c RootFolder.Parent Is Nothing in one expression +// d TypeName(RootFolder.Parent) +// e Dim f As Folder = RootFolder.Parent, then "read" +// f the control: the project folder's Parent, printed by its Path +// +// Each test states what BETA 983, 995 and 997 do, which is the same: the +// compiler crashes in cases a to e, at the read of the root's Parent, before +// anything is done with the value, and not in the control, f, where the +// parent is the root. So the lane passes while the bug is there. If a test +// fails after an IDE update, the IDE has changed: update P21 in WIP.HelpAddin.md, the Parent +// section of FileSystemItem.md, the entry in BUGS-TO-REPORT.md +// and the reproducer in bugs/, and then this file. +// +// Run it with addin-test.bat, which gives it a lane; on its own it is skipped. + +import assert from "node:assert/strict"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { linesSince } from "../../scripts/lib/tb-ide-console.mjs"; +import { scenario } from "./scenario.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const HOST = path.join(HERE, "host"); +const PROBE = path.join(HERE, "probes", "parent"); + +// The environment variable the add-in reads to pick its case. +const CASE_ENV = "TB_P21_CASE"; + +// The runs of the add-in in a stretch of the console: each starts at a +// "[P21] start" line, and holds the lines after it. A crashed compiler +// is restarted, and the add-in runs again in the new one. +function runsOf(lines, which) { + const runs = []; + for (const l of lines) { + const m = /^\[P21\] (\S+) (.*)$/.exec(l); + if (!m || m[1] !== which) continue; + if (m[2] === "start") runs.push([]); + if (runs.length) runs.at(-1).push(m[2]); + } + return runs; +} + +scenario("P21: what reading the Parent of the file system's root does", (lane) => { + before(async () => { + await lane.addAddin(PROBE); + }); + + // Open the host project with the add-in set to a case. Returns what it + // printed, in runs, and whether the compiler crashed. The lane throws about + // a crash twice: from open, which refuses a project whose compile has not + // settled, and from closeProject, which reports the crash it finds; both + // are expected here, and any other failure is not. + // + // An IDE now and then starts and prints nothing, and the lane waits out its + // timeout (2 of 28 runs on BETA 983 to 997, never twice running): that run + // says nothing about the case, so it is repeated once. + async function observe(which) { + const first = await observeOnce(which); + return first.runs.length > 0 ? first : observeOnce(which); + } + + async function observeOnce(which) { + let crashed = false; + let opened = null; // what open threw, if it did + const note = (e) => { + if (/compiler crashed|Safe Mode|restarted \d+x/.test(e.message)) crashed = true; + else throw e; + }; + try { + await lane.open(HOST, { env: { [CASE_ENV]: which }, timeout: 60 * 1000 }); + } catch (e) { + opened = e.message.split("\n")[0]; + } + let lines = []; + try { + // A case that does not crash prints its last line as the project loads; + // one that crashes has printed what it will by the time the lane gives up. + if (lane.c) lines = await linesSince(lane.c); + } finally { + try { + await lane.closeProject(); + } catch (e) { + note(e); + } + } + return { crashed, opened, runs: runsOf(lines, which), all: lines.filter((l) => l.startsWith("[P21]")) }; + } + + // What each case does in the build under test: whether the compiler crashes, + // and the lines each run of the add-in prints. A crashing case prints its + // "start" and the line before the step, and nothing after: the step is the + // read of Parent, or the expression that holds it. + const CRASH = (step) => ({ crashed: true, run: ["start", step] }); + const EXPECT = { + none: { crashed: false, run: ["start", "done"], runs: 1, title: "the control that reads nothing" }, + a: { ...CRASH("before Parent"), title: "Dim o As Object = RootFolder.Parent crashes at the read" }, + b: { ...CRASH("before Parent"), title: "the same, then o Is Nothing: crashes at the read, so before the test" }, + c: { ...CRASH("before Is Nothing"), title: "RootFolder.Parent Is Nothing in one expression crashes" }, + d: { ...CRASH("before TypeName"), title: "TypeName(RootFolder.Parent) crashes" }, + e: { ...CRASH("before Parent"), title: "Dim f As Folder = RootFolder.Parent crashes at the read" }, + f: { + crashed: false, + run: ["start", "before Parent", "read", "Path = twinbasic:/", "done"], + runs: 1, + title: "the project folder's Parent reads, and is the file system's root", + }, + }; + + for (const which of Object.keys(EXPECT)) { + test(`case ${which}: ${EXPECT[which].title}`, async (t) => { + const want = EXPECT[which]; + const r = await observe(which); + t.diagnostic(`case ${which}: ${r.crashed ? "CRASHED" : "no crash"}, ${r.runs.length} run(s), open: ${r.opened}`); + for (const l of r.all) t.diagnostic(l); + assert.equal(r.crashed, want.crashed, `crashed: ${r.opened}`); + // The IDE restarts a crashed compiler, and the add-in runs again in each + // one, so every run stops at the same step. + assert.ok(r.runs.length > 0, "the add-in printed nothing"); + if (want.runs) assert.equal(r.runs.length, want.runs); + for (const run of r.runs) assert.deepEqual(run, want.run); + }); + } +}); diff --git a/test/addin/probes/parent/Settings b/test/addin/probes/parent/Settings new file mode 100644 index 000000000..47fe088b6 --- /dev/null +++ b/test/addin/probes/parent/Settings @@ -0,0 +1,42 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ParentProbe", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard DLL", + "project.description": "P21 in WIP.HelpAddin.md: what reading the Parent of the file system's root does. test/addin/parent.test.mjs builds it into the copy of the install its lane makes; never put it in your own %APPDATA%\\twinBASIC\\addins or in a real install's addins folder.", + "project.exportPathIsV2": true, + "project.id": "{7B247100-0000-4000-9000-7B247100000B}", + "project.name": "ParentProbe", + "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", + "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 + } + ], + "project.settingsVersion": 1, + "project.warnings": { + "errors": [], + "ignored": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/test/addin/probes/parent/Sources/MainModule.twin b/test/addin/probes/parent/Sources/MainModule.twin new file mode 100644 index 000000000..c478640cb --- /dev/null +++ b/test/addin/probes/parent/Sources/MainModule.twin @@ -0,0 +1,97 @@ +' P21 in WIP.HelpAddin.md: what reading the Parent of the virtual file system's +' root folder does. The documentation says it is Nothing; an add-in that tested +' `item.Parent Is Nothing` on the root crashed the compiler. parent.test.mjs +' builds this add-in into the copy of the install its lane makes, and opens a +' project once per case, naming the case in the environment variable +' TB_P21_CASE, which an add-in reads with Environ$ (P10). Each case is run in a +' compiler of its own, since a crash ends the process the add-in runs in. +' +' Every line starts "[P21] ", so the last one printed shows which step +' the compiler died in. A case prints "start" first and "done" last. +' +' none the control: reads nothing +' a Dim o As Object = RootFolder.Parent, then "read" +' b the same, then o Is Nothing +' c RootFolder.Parent Is Nothing in one expression +' d TypeName(RootFolder.Parent) +' e Dim f As Folder = RootFolder.Parent, then "read" +' f the control with a parent: the project folder's Parent, by its Path +Module MainModule + [DllExport] + Public Function tbCreateCompilerAddin(ByVal Host As Host) As AddIn + Return New ParentProbeAddin(Host) + End Function + + Public Sub Say(ByVal Host As Host, ByVal which As String, ByVal text As String) + Host.DebugConsole.PrintText "[P21] " & which & " " & text + End Sub + + Public Sub CaseA(ByVal Host As Host) + Say Host, "a", "before Parent" + Dim o As Object = Host.FileSystem.RootFolder.Parent + Say Host, "a", "read" + End Sub + + Public Sub CaseB(ByVal Host As Host) + Say Host, "b", "before Parent" + Dim o As Object = Host.FileSystem.RootFolder.Parent + Say Host, "b", "read" + Dim isNone As Boolean = o Is Nothing + Say Host, "b", "tested Is Nothing = " & isNone + End Sub + + Public Sub CaseC(ByVal Host As Host) + Say Host, "c", "before Is Nothing" + Dim isNone As Boolean = Host.FileSystem.RootFolder.Parent Is Nothing + Say Host, "c", "tested Is Nothing = " & isNone + End Sub + + Public Sub CaseD(ByVal Host As Host) + Say Host, "d", "before TypeName" + Dim t As String = TypeName(Host.FileSystem.RootFolder.Parent) + Say Host, "d", "TypeName = " & t + End Sub + + Public Sub CaseE(ByVal Host As Host) + Say Host, "e", "before Parent" + Dim f As Folder = Host.FileSystem.RootFolder.Parent + Say Host, "e", "read" + End Sub + + Public Sub CaseF(ByVal Host As Host) + Say Host, "f", "before Parent" + Dim f As Folder = Host.CurrentProject.RootFolder.Parent + Say Host, "f", "read" + Say Host, "f", "Path = " & f.Path + End Sub + + Public Sub Run(ByVal Host As Host, ByVal which As String) + Say Host, which, "start" + Select Case which + Case "a": CaseA Host + Case "b": CaseB Host + Case "c": CaseC Host + Case "d": CaseD Host + Case "e": CaseE Host + Case "f": CaseF Host + End Select + Say Host, which, "done" + End Sub +End Module + +Private Class ParentProbeAddin + Implements AddIn + Private WithEvents Host As Host + + Public Sub New(ByVal Host As Host) + Set Me.Host = Host + End Sub + + Private Property Get AddIn_Name() As String + Return "ParentProbe AddIn" + End Property + + Private Sub Host_OnProjectLoaded() + Run Host, Environ$("TB_P21_CASE") + End Sub +End Class diff --git a/test/addin/probes/references-bare/Settings b/test/addin/probes/references-bare/Settings new file mode 100644 index 000000000..f04c89b06 --- /dev/null +++ b/test/addin/probes/references-bare/Settings @@ -0,0 +1,30 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ReferencesBare", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "P20 in WIP.HelpAddin.md: a project that references nothing but OLE Automation. test/addin/references.test.mjs opens it; it is not an add-in.", + "project.exportPathIsV2": true, + "project.id": "{7B247200-0000-4000-9000-7B2472000003}", + "project.name": "ReferencesBare", + "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 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "ignored": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/test/addin/probes/references-bare/Sources/Main.twin b/test/addin/probes/references-bare/Sources/Main.twin new file mode 100644 index 000000000..db31ef9e2 --- /dev/null +++ b/test/addin/probes/references-bare/Sources/Main.twin @@ -0,0 +1,5 @@ +Module Main + Public Sub Main() + Debug.Print "ReferencesBare" + End Sub +End Module diff --git a/test/addin/probes/references-defaults/Settings b/test/addin/probes/references-defaults/Settings new file mode 100644 index 000000000..236ccba32 --- /dev/null +++ b/test/addin/probes/references-defaults/Settings @@ -0,0 +1,46 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ReferencesDefaults", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "P20 in WIP.HelpAddin.md: a Standard EXE project as the IDE's template makes it, with the references the template gives. test/addin/references.test.mjs opens it; it is not an add-in.", + "project.exportPathIsV2": true, + "project.id": "{7B247200-0000-4000-9000-7B2472000002}", + "project.name": "ReferencesDefaults", + "project.optionExplicit": true, + "project.references": [ + { + "hasBeenSplit": true, + "id": "{F50B82D0-DCAB-43FE-9631-11959D4A4728}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WinNativeForms Package", + "path32": "/Packages/WindowsControlsPackage", + "path64": "/Packages/WindowsControlsPackage", + "publishedDate": "09-FEB-2022", + "publishedTime": "15:48:54", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WindowsControlsPackage", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 31 + }, + { + "id": "{00020430-0000-0000-C000-000000000046}", + "lcid": 0, + "name": "OLE Automation", + "path32": "C:\\Windows\\SysWow64\\stdole2.tlb", + "path64": "C:\\Windows\\System32\\stdole2.tlb", + "versionMajor": 2, + "versionMinor": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Form1", + "project.warnings": { + "errors": [], + "ignored": [] + }, + "runtime.useUnicodeStandardLibrary": true +} \ No newline at end of file diff --git a/test/addin/probes/references-defaults/Sources/Form1.tbform b/test/addin/probes/references-defaults/Sources/Form1.tbform new file mode 100644 index 000000000..fb6e30ba8 --- /dev/null +++ b/test/addin/probes/references-defaults/Sources/Form1.tbform @@ -0,0 +1,83 @@ +[ + { + "AlwaysShowKeyboardCues": false, + "Appearance": "vbAppear3d", + "AutoRedraw": false, + "BackColor": -2147483633, + "BorderStyle": "vbSizable", + "Caption": "Form1", + "ClipControls": true, + "ControlBox": true, + "DrawMode": "vbCopyPen", + "DrawStyle": "vbSolid", + "DrawWidth": 1, + "Enabled": true, + "FillColor": 0, + "FillStyle": "vbFSTransparent", + "FontBold": false, + "FontItalic": false, + "FontName": "Segoe UI", + "FontSize": 8, + "FontStrikethru": false, + "FontTransparent": true, + "FontUnderline": false, + "ForeColor": -2147483630, + "FormDesignerId": "{EAEAEAEA-EAEA-EAEA-EAEA-EAEAEAEAEA01}", + "HasDC": true, + "Height": 300, + "HelpContextID": 0, + "Icon": [], + "KeyPreview": false, + "Left": 0, + "LinkMode": "vbLinkNone", + "LinkTopic": null, + "MDIChild": false, + "MaxButton": true, + "MaxHeight": 0, + "MaxWidth": 0, + "MinButton": true, + "MinHeight": 0, + "MinWidth": 0, + "MouseIcon": [], + "MousePointer": "vbDefault", + "Moveable": true, + "Name": "Form1", + "NegotiateMenus": false, + "OLEDropMode": "vbOLEDropNone", + "Opacity": 100, + "Palette": [], + "PaletteMode": "vbPaletteModeHalftone", + "Picture": [], + "PictureDpiScaling": false, + "RightToLeft": false, + "ScaleHeight": 4500, + "ScaleLeft": 0, + "ScaleMode": "vbTwips", + "ScaleTop": 0, + "ScaleWidth": 4500, + "ShowInTaskbar": true, + "StartUpPosition": "vbStartUpScreen", + "TabFocusAutoSelect": false, + "Tag": null, + "Top": 0, + "TopMost": false, + "TransparencyKey": -1, + "Visible": true, + "WhatsThisButton": false, + "WhatsThisHelp": false, + "Width": 300, + "WindowState": "vbNormal", + "__IDEOptions": { + "alignToGrid": false, + "gridHeight": 10, + "gridWidth": 10, + "lockedControls": [], + "multiColoredGrabbers": false, + "showGrid": true, + "showOutlines": false + }, + "__lastUpdateMarker": 2061908507, + "_className": "Form", + "_clsid": "{33AD4F38-6699-11CF-B70C-00AA0060D393}" + } +] \ No newline at end of file diff --git a/test/addin/probes/references-defaults/Sources/Form1.twin b/test/addin/probes/references-defaults/Sources/Form1.twin new file mode 100644 index 000000000..68e37d655 --- /dev/null +++ b/test/addin/probes/references-defaults/Sources/Form1.twin @@ -0,0 +1,9 @@ +[Description("")] +[FormDesignerId("EAEAEAEA-EAEA-EAEA-EAEA-EAEAEAEAEA01")] +[PredeclaredId] +Class Form1 + + Sub New() + End Sub + +End Class \ No newline at end of file diff --git a/test/addin/probes/references-many/Settings b/test/addin/probes/references-many/Settings new file mode 100644 index 000000000..e56f05892 --- /dev/null +++ b/test/addin/probes/references-many/Settings @@ -0,0 +1,199 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ReferencesMany", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard EXE", + "project.description": "P20 in WIP.HelpAddin.md: a project that references every built-in package. test/addin/references.test.mjs opens it; it is not an add-in.", + "project.exportPathIsV2": true, + "project.id": "{7B247200-0000-4000-9000-7B2472000004}", + "project.name": "ReferencesMany", + "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": "{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 + }, + { + "id": "{C192FB39-64CA-4D9B-B477-A5502F48EFCC}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - App global class object", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "AppGlobalClassProject", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 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": "{697A5CBF-2012-48BD-9D2F-3EA64CDE149A}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] Custom Controls Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "CustomControlsPackage", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 5 + }, + { + "id": "{EA18E2B1-80BA-478D-AC7C-E955E6AA1B05}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - CustomControls DESIGNER Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "CustomControls", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 24 + }, + { + "id": "{9847A10D-8987-44B2-8B87-EA77DEF7F1D9}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - Unit Testing Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "Assert", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 24 + }, + { + "id": "{5D19653F-F49E-41F2-ABF8-16E473A89A7C}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] WebView2 Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WebView2Package", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 3 + }, + { + "autoCtlExt": true, + "id": "{AD2CF9ED-0145-43B7-8B4C-671D0E840620}", + "isCompilerPackage": true, + "licence": "", + "name": "[COMPILER PACKAGE] twinBASIC - Chromium Embedded Framework Package v145", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "cefPackage", + "versionBuild": 0, + "versionMajor": 1, + "versionMinor": 0, + "versionRevision": 0 + }, + { + "id": "{4DF189FD-8CAD-4ACA-8AD7-A7FC925C4EF1}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - WinEventLogLib", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WinEventLogLib", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 1, + "versionRevision": 0 + }, + { + "id": "{C1C6E5F9-E3EF-4961-9C29-73F8E9A2FAD0}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - WinNamedPipesLib", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WinNamedPipesLib", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 1, + "versionRevision": 0 + }, + { + "id": "{D63CB78D-9F5B-42FE-B94C-A545F2B2BFC3}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - Common Controls Compatibility Package", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WinNativeCommonCtls", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 0, + "versionRevision": 1 + }, + { + "id": "{31FD080F-5BFB-46F0-AD52-3FB908CA1766}", + "isCompilerPackage": true, + "licence": "MIT", + "name": "[COMPILER PACKAGE] twinBASIC - WinServicesLib", + "path32": "", + "path64": "", + "publisher": "TWINBASIC-COMPILER", + "symbolId": "WinServicesLib", + "versionBuild": 0, + "versionMajor": 0, + "versionMinor": 1, + "versionRevision": 0 + } + ], + "project.settingsVersion": 1, + "project.startupObject": "Sub Main", + "project.warnings": { + "errors": [], + "ignored": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/test/addin/probes/references-many/Sources/Main.twin b/test/addin/probes/references-many/Sources/Main.twin new file mode 100644 index 000000000..eca51bbd2 --- /dev/null +++ b/test/addin/probes/references-many/Sources/Main.twin @@ -0,0 +1,5 @@ +Module Main + Public Sub Main() + Debug.Print "ReferencesMany" + End Sub +End Module diff --git a/test/addin/probes/references/Settings b/test/addin/probes/references/Settings new file mode 100644 index 000000000..06f31f5c8 --- /dev/null +++ b/test/addin/probes/references/Settings @@ -0,0 +1,42 @@ +{ + "configuration.inherits": "Defaults", + "project.appTitle": "ReferencesProbe", + "project.buildPath": "${SourcePath}\\Build\\${ProjectName}_${Architecture}.${FileExtension}", + "project.buildType": "Standard DLL", + "project.description": "P20 in WIP.HelpAddin.md: which packages the IDE's virtual file system lists under a project's Packages folder. test/addin/references.test.mjs builds it into the copy of the install its lane makes; never put it in your own %APPDATA%\\twinBASIC\\addins or in a real install's addins folder.", + "project.exportPathIsV2": true, + "project.id": "{7B247100-0000-4000-9000-7B247100000A}", + "project.name": "ReferencesProbe", + "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", + "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 + } + ], + "project.settingsVersion": 1, + "project.warnings": { + "errors": [], + "ignored": [] + }, + "runtime.useUnicodeStandardLibrary": true +} diff --git a/test/addin/probes/references/Sources/MainModule.twin b/test/addin/probes/references/Sources/MainModule.twin new file mode 100644 index 000000000..2991c9db7 --- /dev/null +++ b/test/addin/probes/references/Sources/MainModule.twin @@ -0,0 +1,154 @@ +' P20 in WIP.HelpAddin.md: which packages the IDE's virtual file system lists +' for a project, so which packages the help add-in can offer. The add-in goes +' through the tree with For Each, from the file system's root folder and from +' the project's, and descends only into folders named "Packages" and their +' direct children, which are the packages. It does this when the project is +' loaded and again two seconds later, and prints a line for each folder it +' meets. Every line starts "[P20] ", where the phase is "load" or +' "timer", and the kind is "root" (a root folder), "child" (a folder in a root +' folder), "reference" (an item in the folder named References), "packages" (a +' folder named Packages) or "package" (a folder in one), and goes on +' " | | | | ". +' A "packages" line goes on " | ". +' A package is one folder shared by every Packages folder that lists it, so a +' Packages folder already printed, which has the same path wherever it is +' found, is not printed again. Loading prints "[P20] load start | | " first, and +' each pass ends with "[P20] done". A compiler restart makes a new +' process, so the process id tells one load from the next. +' +' A For Each over a folder is finished before the next one starts: the folders +' it meets are collected first, and the walk goes on through the collection. +Module MainModule + Private Declare PtrSafe Function GetCurrentProcessId Lib "kernel32" () As Long + + ' How deep nested Packages folders are followed. + Private Const MAX_DEPTH As Long = 8 + + [DllExport] + Public Function tbCreateCompilerAddin(ByVal Host As Host) As AddIn + Return New ReferencesProbeAddin(Host) + End Function + + Public Function ProcessId() As Long + Return GetCurrentProcessId() + End Function + + ' The path of a folder's parent, or "(none)". + Private Function ParentPath(ByVal item As FileSystemItem) As String + If item.Path = "twinbasic:/" Then Return "(none)" + Return item.Parent.Path + End Function + + ' The line for one folder. + Public Function FolderLine(ByVal phase As String, ByVal kind As String, ByVal f As Folder) As String + Return "[P20] " & phase & " " & kind & " | " & f.Path & " | " & f.Name & " | " & f.IsPackagesFolder & _ + " | " & ParentPath(f) + End Function + + ' The line for an item that is not necessarily a folder. + Public Function ItemLine(ByVal phase As String, ByVal kind As String, ByVal item As FileSystemItem) As String + Return "[P20] " & phase & " " & kind & " | " & item.Path & " | " & item.Name & " | " & item.Type & _ + " | " & ParentPath(item) + End Function + + ' The folders in a folder, collected before anything else is enumerated. + Public Function FoldersIn(ByVal f As Folder) As Collection + Dim list As New Collection + Dim item As FileSystemItem + For Each item In f + If TypeOf item Is Folder Then list.Add item + Next + Return list + End Function + + ' The items in a folder, collected in the same way. + Public Function ItemsIn(ByVal f As Folder) As Collection + Dim list As New Collection + Dim item As FileSystemItem + For Each item In f + list.Add item + Next + Return list + End Function + + ' Print the packages under a Packages folder: a line for the folder with + ' the names of its folders, then a line for each package, and the same for + ' the Packages folder inside it. A package can be in many Packages folders + ' and shows the same path in each, so a Packages folder already printed + ' (its path is in `seen`) is not printed again. + Public Sub PrintPackages(ByVal Host As Host, ByVal phase As String, ByVal packages As Folder, _ + ByRef seen As String, ByVal depth As Long) + If InStr(seen, "|" & packages.Path & "|") > 0 Then Exit Sub + seen = seen & "|" & packages.Path & "|" + Dim names As String + Dim v As Variant + For Each v In FoldersIn(packages) + Dim listed As Folder = v + names = names & IIf(Len(names) > 0, ",", "") & listed.Name + Next + Host.DebugConsole.PrintText FolderLine(phase, "packages", packages) & " | " & names + If depth > MAX_DEPTH Then Exit Sub + For Each v In FoldersIn(packages) + Dim package As Folder = v + Host.DebugConsole.PrintText FolderLine(phase, "package", package) + Dim w As Variant + For Each w In FoldersIn(package) + Dim inner As Folder = w + If inner.Name = "Packages" Then PrintPackages Host, phase, inner, seen, depth + 1 + Next + Next + End Sub + + ' Print a root folder, its folders, the items in its References folder, and + ' the packages of its Packages folder. + Public Sub PrintRoot(ByVal Host As Host, ByVal phase As String, ByVal root As Folder) + Dim seen As String + Host.DebugConsole.PrintText FolderLine(phase, "root", root) + Dim v As Variant + For Each v In FoldersIn(root) + Dim child As Folder = v + Host.DebugConsole.PrintText FolderLine(phase, "child", child) + If child.Name = "References" Then + Dim w As Variant + For Each w In ItemsIn(child) + Dim ref As FileSystemItem = w + Host.DebugConsole.PrintText ItemLine(phase, "reference", ref) + Next + End If + If child.Name = "Packages" Then PrintPackages Host, phase, child, seen, 1 + Next + End Sub + + Public Sub PrintAll(ByVal Host As Host, ByVal phase As String) + PrintRoot Host, phase, Host.FileSystem.RootFolder + PrintRoot Host, phase, Host.CurrentProject.RootFolder + Host.DebugConsole.PrintText "[P20] " & phase & " done" + End Sub +End Module + +Private Class ReferencesProbeAddin + Implements AddIn + Private WithEvents Host As Host + Private WithEvents Later As AddinTimer + + Public Sub New(ByVal Host As Host) + Set Me.Host = Host + End Sub + + Private Property Get AddIn_Name() As String + Return "ReferencesProbe AddIn" + End Property + + Private Sub Host_OnProjectLoaded() + Host.DebugConsole.PrintText "[P20] load start | " & Host.CurrentProject.Name & " | " & ProcessId() + PrintAll Host, "load" + Set Later = New AddinTimer + Later.Interval = 2000 + Later.Enabled = True + End Sub + + Private Sub Later_Timer() + Later.Enabled = False + PrintAll Host, "timer" + End Sub +End Class diff --git a/test/addin/references.test.mjs b/test/addin/references.test.mjs new file mode 100644 index 000000000..c1cea7658 --- /dev/null +++ b/test/addin/references.test.mjs @@ -0,0 +1,466 @@ +// P20 in WIP.HelpAddin.md, measured: which packages the IDE's virtual file +// system lists for a project, so which packages the help add-in can offer. The +// ReferencesProbe add-in (probes/references) walks the tree with For Each, +// from the file system's root and from the project's, descending only into +// folders named Packages and their direct children, and prints a line for +// each folder. It does this when the project is loaded and again two seconds +// later. Three projects are opened in turn: references-defaults, which +// references what the IDE's Standard EXE template does; references-bare, +// which references only OLE Automation; and references-many, which references +// every built-in package. The last test adds a reference to the first while the +// add-in is loaded and removes it again, on the References page, as a person +// does. +// +// Each test states what BETA 995 and 997 do, which is the same. If one fails after an IDE update, the +// IDE has changed: update P20 in WIP.HelpAddin.md, and the Folder page +// (docs/Reference/Built-In/tbIDE/Folder.md), and then this file. +// +// Run it with addin-test.bat, which gives it a lane; on its own it is skipped. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { compileOutcome, compilerPid, sleep, waitForCompile } from "../../scripts/lib/tb-ide.mjs"; +import { consoleMark, linesSince } from "../../scripts/lib/tb-ide-console.mjs"; +import { click, clickAt, messageBoxes, waitFor } from "../../scripts/lib/tb-operate.mjs"; +import { scenario } from "./scenario.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PROBE = path.join(HERE, "probes", "references"); +const SYMBOLS = path.join(HERE, "..", "..", "add-in", "Resources", "SYMBOLS", "symbols.json"); + +// The package names the add-in's symbol index knows. +const INDEXED = Object.keys(JSON.parse(readFileSync(SYMBOLS, "utf8")).packages); + +// What the probe printed, as entries. A line is "[P20] | | +// | | ", and a "packages" line goes on " | ", the +// names of the folders in it, in the order For Each gives them. `raw` is the +// line after the phase, to compare the two phases. +const LINE = /^\[P20\] (load|timer) (root|child|reference|packages|package) \| (.*)$/; +function entries(lines) { + return lines.flatMap((l) => { + const m = LINE.exec(l); + if (!m) return []; + const [path, name, flag, parent, names] = m[3].split(" | "); + return [ + { + phase: m[1], + kind: m[2], + path, + name, + flag, + parent, + names: names ? names.split(",") : [], + raw: `${m[2]} | ${m[3]}`, + }, + ]; + }); +} + +// The loads in a stretch of the console, each as "load start | | +// " and the entries after it, in the instance of the add-in the compiler +// made. +function loads(lines) { + const out = []; + for (const l of lines) { + const start = /^\[P20\] load start \| (.+) \| (\d+)$/.exec(l); + if (start) out.push({ project: start[1], pid: Number(start[2]), lines: [] }); + else if (l.startsWith("[P20] ") && out.length) out.at(-1).lines.push(l); + } + return out.map((o) => ({ ...o, entries: entries(o.lines), done: o.lines.filter((l) => l.endsWith(" done")) })); +} + +// The names in the project's own Packages folder, and in the Packages folder of +// a package, by the package's name. +const topNames = (es, project) => + es.find((e) => e.kind === "packages" && e.path === `twinbasic:/${project}/Packages`)?.names; +const nestedNames = (es, owner) => + es.find((e) => e.kind === "packages" && e.path.endsWith(`/${owner}/Packages`))?.names; +const sorted = (a) => [...a].sort(); + +// The pass of one phase of a load, by its entries. +const phaseOf = (load, phase) => load.entries.filter((e) => e.phase === phase); + +// ---------------------------------------------------------------- the References page +// Taken from ide/packages.test.mjs, which says why each part is as it is. + +// The open Settings editor's working copy of the project's settings. +const CONFIG_JS = `[...document.querySelectorAll("*")].find((e) => e.globalVars?.configData)?.globalVars`; +// The working copy's references, by symbol. A tick or an untick changes it at once. +const REFERENCES_JS = `${CONFIG_JS}.configData["project.references"].map((r) => r.symbolId ?? r.name)`; +// Every row of the References page, as text, to tell when the lists stop changing. +const ALL_ROWS_JS = `[...document.querySelectorAll(".referencesListRow")].map((r) => r.parentElement.className + " " + r.textContent).join("\\n")`; +// The sequence numbers of the compiler socket's requests still awaiting an answer. +const WAITING_JS = `JSON.stringify(Object.keys(debugSocket.requestsAwaitingResponse))`; +// The two lists of rows: the references the project has, and Available Packages. +const INSTALLED = "referencesListNew"; +const AVAILABLE = "referencesListNewAvailable"; + +// Open the Settings editor on the References page, and wait for it to load: for +// the requests sent after it was opened to be answered. +async function openReferences(c) { + const older = JSON.parse(await c.evaluate(WAITING_JS)); + const newer = async () => JSON.parse(await c.evaluate(WAITING_JS)).filter((k) => !older.includes(k)); + await c.evaluate(`executeIdeCommand("tbProject_ShowReferences"), null`); + assert.ok(await waitFor(c, (c) => c.evaluate(`!!${CONFIG_JS}`), { timeout: 20 * 1000 }), "no Settings editor"); + assert.ok( + await waitFor(c, async () => (await newer()).length === 0, { timeout: 60 * 1000 }), + "the References page never finished loading", + ); +} + +// Click the tick box of the row in `list` that names a package and is in the +// state `ticked`, with a real click, as a person does, once the rows have +// stopped changing: Available Packages also lists TWINSERV's packages, which +// arrive by a request the page does not count as pending, and a click while +// they are being added lands on another row. Returns the references before +// and after. +async function tick(c, name, { ticked, list }) { + let rows = null; + let since = 0; + const stable = await waitFor( + c, + async () => { + const now = await c.evaluate(ALL_ROWS_JS); + if (now !== rows) [rows, since] = [now, Date.now()]; + return Date.now() - since >= 2000; + }, + { timeout: 30 * 1000 }, + ); + assert.ok(stable, "the References page's rows never stopped changing"); + const find = `(() => { + const r = [...document.querySelectorAll(".referencesListRow")].find((r) => + r.querySelector(".referencesListColName2, .referencesListColSymbol")?.textContent.includes(${JSON.stringify(name)}) + && r.parentElement.className === ${JSON.stringify(list)} + && !!r.querySelector(".referencesListColEnabled .tickIcon2") === ${ticked}); + if (!r) return null; + const b = r.querySelector(".referencesListColEnabled"); + b.scrollIntoView({ block: "center" }); + const q = b.getBoundingClientRect(); + return { x: q.x + q.width / 2, y: q.y + q.height / 2 }; + })()`; + const where = await waitFor(c, (c) => c.evaluate(find), { timeout: 15 * 1000 }); + assert.ok(where, `no ${ticked ? "ticked" : "unticked"} row for ${name} in ${list}`); + const before = await c.evaluate(REFERENCES_JS); + await clickAt(c, where.x, where.y); + await sleep(1500); + return { before, after: await c.evaluate(REFERENCES_JS) }; +} + +scenario("P20: the packages a project references, in the virtual file system", (lane) => { + before(async () => { + await lane.addAddin(PROBE); + }); + + // Open a host project, let the add-in load and its timer fire, and keep what + // it printed. The project stays open if `keep`. + async function observe(host, { keep = false } = {}) { + const c = await lane.open(path.join(HERE, "probes", `references-${host}`), { + folder: path.join(lane.work, host), + }); + const timed = await waitFor(c, async (c) => loads(await linesSince(c)).some((l) => l.done.length === 2), { + timeout: 60 * 1000, + }); + assert.ok(timed, `the add-in never finished its second pass in ${host}`); + const result = loads(await linesSince(c)); + if (!keep) await lane.closeProject(); + return { c, loads: result }; + } + const seen = {}; + const once = (host) => (seen[host] ??= observe(host)); + + test("the add-in loads once per project, and prints both passes", async () => { + for (const host of ["defaults", "bare", "many"]) { + const { loads: ls } = await once(host); + assert.equal(ls.length, 1, `${host}: ${ls.length} loads`); + assert.equal(ls[0].project, `References${host[0].toUpperCase()}${host.slice(1)}`); + assert.deepEqual(ls[0].done, ["[P20] load done", "[P20] timer done"]); + } + }); + + test("Q3: the list is complete when Host_OnProjectLoaded runs: two seconds later it is the same", async () => { + for (const host of ["defaults", "bare", "many"]) { + const [l] = (await once(host)).loads; + const load = phaseOf(l, "load").map((e) => e.raw); + const timer = phaseOf(l, "timer").map((e) => e.raw); + assert.ok(load.length > 10, `${host}: only ${load.length} lines`); + assert.deepEqual(timer, load, `${host}: the second pass differs from the first`); + } + }); + + test("Q1: the file system's root is twinbasic:/, and holds a folder named for the project, which holds Packages", async () => { + const [l] = (await once("defaults")).loads; + const es = phaseOf(l, "load"); + const roots = es.filter((e) => e.kind === "root"); + assert.deepEqual( + roots.map((e) => [e.path, e.name, e.flag, e.parent]), + [ + ["twinbasic:/", "", "False", "(none)"], + ["twinbasic:/ReferencesDefaults", "ReferencesDefaults", "False", "twinbasic:/"], + ], + ); + // The folders of the file system's root, then of the project's. For Each + // gives the project's in a different order each time the project loads. + assert.deepEqual( + sorted(es.filter((e) => e.kind === "child").map((e) => e.name)), + sorted([ + "ReferencesDefaults", + "References", + "Sources", + "Resources", + "Packages", + "Miscellaneous", + "ImportedTypeLibraries", + ]), + ); + const packages = es.find((e) => e.kind === "child" && e.name === "Packages"); + assert.deepEqual( + [packages.path, packages.flag, packages.parent], + ["twinbasic:/ReferencesDefaults/Packages", "True", "twinbasic:/ReferencesDefaults"], + ); + }); + + test("Q1: IsPackagesFolder is true for the project's Packages folder and for each package's, false for a package's folder", async () => { + for (const host of ["defaults", "bare", "many"]) { + const es = phaseOf((await once(host)).loads[0], "load"); + const lists = es.filter((e) => e.kind === "packages"); + assert.ok(lists.length > 1, host); + assert.ok( + lists.every((e) => e.name === "Packages" && e.flag === "True"), + host, + ); + const packages = es.filter((e) => e.kind === "package"); + assert.ok(packages.length > 1, host); + assert.ok( + packages.every((e) => e.flag === "False"), + host, + ); + assert.ok( + es.filter((e) => e.kind === "child" && e.name !== "Packages").every((e) => e.flag === "False"), + host, + ); + } + }); + + test("Q1: a package's folder is named for the package's project, not for its symbol, its description or its install folder", async () => { + // The template's reference to VB has the symbol WindowsControlsPackage and + // the description "twinBASIC - VB Compatibility Package (Forms)"; the + // install folder of Assert is TwinBasicAssertions, of CustomControlsPackage + // is {697A5CBF-...}_CustomControlsPackage, of AppGlobalClassProject + // {C192FB39-...}_AppGlobalClassObject. + const defaults = phaseOf((await once("defaults")).loads[0], "load"); + assert.deepEqual(sorted(topNames(defaults, "ReferencesDefaults")), ["VB", "VBA", "VBRUN"]); + const many = phaseOf((await once("many")).loads[0], "load"); + const top = topNames(many, "ReferencesMany"); + for (const name of ["Assert", "AppGlobalClassProject", "CustomControls", "CustomControlsPackage", "cefPackage"]) { + assert.ok(top.includes(name), `${name} is not in ${top}`); + } + for (const bad of [ + "TwinBasicAssertions", + "AppGlobalClassObject", + "WindowsControlsPackage", + "CEF", + "cefPackage145", + ]) { + assert.ok(!top.includes(bad), `${bad} is in ${top}`); + } + }); + + test("Q1: the names are the keys of the symbol index's packages, but for VBComDlg, which only another package references", async () => { + const many = phaseOf((await once("many")).loads[0], "load"); + const every = new Set(many.filter((e) => e.kind === "package").map((e) => e.name)); + assert.deepEqual(sorted([...every].filter((n) => !INDEXED.includes(n))), ["VBComDlg"]); + // The index has every name the project lists, whichever level. + assert.deepEqual(sorted(INDEXED.filter((n) => !every.has(n))), []); + assert.ok(!topNames(many, "ReferencesMany").includes("VBComDlg")); + assert.deepEqual(sorted(nestedNames(many, "WinNativeCommonCtls")), ["VB", "VBComDlg", "VBRUN", "VBA"].sort()); + }); + + test("Q1: a package shows the Path and Parent of the first Packages folder it was found in, whichever lists it", async () => { + // VB lists VBRUN and VBA, and so do the project's own Packages folder: the + // project's list shows the same two folders, with the paths under VB. + const defaults = phaseOf((await once("defaults")).loads[0], "load"); + const vba = defaults.filter((e) => e.kind === "package" && e.name === "VBA"); + assert.ok(vba.length >= 2); + assert.ok( + vba.every((e) => e.path === "twinbasic:/ReferencesDefaults/Packages/VB/Packages/VBA"), + JSON.stringify(vba.map((e) => e.path)), + ); + assert.ok(vba.every((e) => e.parent === "twinbasic:/ReferencesDefaults/Packages/VB/Packages")); + // With no VB to list them first, they sit directly in the project's Packages folder. + const bare = phaseOf((await once("bare")).loads[0], "load"); + assert.deepEqual( + bare.filter((e) => e.kind === "package").map((e) => e.path), + ["twinbasic:/ReferencesBare/Packages/VBRUN", "twinbasic:/ReferencesBare/Packages/VBA"], + ); + }); + + test("Q2: a project with the template's references lists VB, VBRUN and VBA, and not AppGlobalClassProject", async () => { + const es = phaseOf((await once("defaults")).loads[0], "load"); + assert.deepEqual(sorted(topNames(es, "ReferencesDefaults")), ["VB", "VBA", "VBRUN"]); + assert.deepEqual(sorted(nestedNames(es, "VB")), ["VBA", "VBRUN"]); + assert.deepEqual(nestedNames(es, "VBA"), []); + assert.deepEqual(nestedNames(es, "VBRUN"), []); + }); + + test("Q2: a project that references only OLE Automation still lists VBRUN and VBA", async () => { + const es = phaseOf((await once("bare")).loads[0], "load"); + assert.deepEqual(sorted(topNames(es, "ReferencesBare")), ["VBA", "VBRUN"]); + }); + + test("Q2: a project that references every built-in package lists them all, and their own references only inside them", async () => { + const es = phaseOf((await once("many")).loads[0], "load"); + assert.deepEqual( + sorted(topNames(es, "ReferencesMany")), + sorted([ + "AppGlobalClassProject", + "Assert", + "cefPackage", + "CustomControls", + "CustomControlsPackage", + "tbIDE", + "VB", + "VBA", + "VBRUN", + "WebView2Package", + "WinEventLogLib", + "WinNamedPipesLib", + "WinNativeCommonCtls", + "WinServicesLib", + ]), + ); + assert.deepEqual(sorted(nestedNames(es, "tbIDE")), ["VBA", "VBRUN"]); + assert.deepEqual(sorted(nestedNames(es, "CustomControlsPackage")), ["CustomControls", "VBA", "VBRUN"]); + assert.deepEqual(sorted(nestedNames(es, "WebView2Package")), ["VB", "VBA", "VBRUN"]); + assert.deepEqual(sorted(nestedNames(es, "cefPackage")), ["VB", "VBA", "VBRUN"]); + assert.deepEqual(nestedNames(es, "AppGlobalClassProject"), []); + }); + + test("a type library is not a package: the References folder holds a file for it", async () => { + for (const host of ["defaults", "bare", "many"]) { + const es = phaseOf((await once(host)).loads[0], "load").filter((e) => e.kind === "reference"); + assert.equal(es.length, 1, host); + // FileSystemItemType.FileVIRTUALDOC, a "stdole" with the library's id and version after U+241E. + assert.equal(es[0].flag, "1"); + assert.match(es[0].name, /^stdole\u{241E}00020430-0000-0000-C000-000000000046_2_0$/u); + } + }); + + test("the Packages folder lists the references newest first, then VBRUN and VBA", async () => { + const many = phaseOf((await once("many")).loads[0], "load"); + assert.deepEqual(topNames(many, "ReferencesMany"), [ + "WinServicesLib", + "WinNativeCommonCtls", + "WinNamedPipesLib", + "WinEventLogLib", + "cefPackage", + "WebView2Package", + "Assert", + "CustomControls", + "CustomControlsPackage", + "tbIDE", + "AppGlobalClassProject", + "VB", + "VBRUN", + "VBA", + ]); + }); + + // -------------------------------------------------------------- adding and removing a reference + let c; + let mark; + let compiler; + + // Click Apply, #SAVEBTN, and wait for the compile to settle. Returns what the + // console showed since, and the message boxes that were open meanwhile. + async function apply() { + mark = await consoleMark(c); + await click(c, { css: "#SAVEBTN" }); + const boxes = []; + const closed = await waitFor( + c, + async () => { + boxes.push(...(await messageBoxes(c))); + return !(await c.evaluate(`!!${CONFIG_JS}`)); + }, + { timeout: 20 * 1000, interval: 100 }, + ); + assert.ok(closed, "the Settings editor stayed open"); + await sleep(2000); + const waited = await waitForCompile(c, { project: lane.project, timeout: 90 * 1000 }); + const outcome = compileOutcome(waited, { name: lane.project }); + assert.ok(outcome.ok, `the compiler did not settle: ${outcome.message}`); + assert.equal(outcome.counts[0], 0, `the compile has errors: ${outcome.rows.join("\n")}`); + return { lines: (await linesSince(c, mark)).filter((l) => !l.startsWith("[P20]")), boxes }; + } + + // The add-in's loads since the mark, once the last has printed its second pass. + async function loadsSince() { + const done = await waitFor( + c, + async (c) => { + const l = loads(await linesSince(c, mark)); + return l.length > 0 && l.at(-1).done.length === 2; + }, + { timeout: 60 * 1000 }, + ); + assert.ok(done, `no complete load since the Apply: ${JSON.stringify(loads(await linesSince(c, mark)))}`); + return loads(await linesSince(c, mark)); + } + + // What an Apply that changes the references writes, and what it does not + // ask: the IDE saves the project and restarts the compiler without a box. + const RESTARTED = [ + "[COMPILER] Project settings changes require compiler-restart", + "[PROJECT] twinBASIC project saving to disk [DONE]", + "restarting from FILE", + ]; + + test("a project is open with the add-in loaded", async () => { + const o = await observe("defaults", { keep: true }); + c = o.c; + compiler = await compilerPid(c); + assert.equal(o.loads[0].pid, compiler); + }); + + test("Q4: ticking a package in Available Packages and applying restarts the compiler without a question, and the new add-in lists the package first", async () => { + await openReferences(c); + await click(c, { css: "div", text: "Available Packages" }); + const r = await tick(c, "WebView2 Package", { ticked: false, list: AVAILABLE }); + assert.deepEqual(sorted(r.after), sorted([...r.before, "WebView2Package"])); + const applied = await apply(); + assert.deepEqual(applied.lines, RESTARTED); + assert.deepEqual(applied.boxes, [], "the IDE asked something"); + const ls = await loadsSince(); + assert.equal(ls.length, 1, "Host_OnProjectLoaded did not run exactly once in the new compiler"); + assert.equal(ls[0].project, "ReferencesDefaults"); + assert.notEqual(ls[0].pid, compiler); + assert.equal(ls[0].pid, await compilerPid(c)); + compiler = ls[0].pid; + const es = phaseOf(ls[0], "load"); + assert.deepEqual(topNames(es, "ReferencesDefaults"), ["WebView2Package", "VB", "VBRUN", "VBA"]); + assert.deepEqual(sorted(nestedNames(es, "WebView2Package")), ["VB", "VBA", "VBRUN"]); + assert.deepEqual( + phaseOf(ls[0], "timer").map((e) => e.raw), + es.map((e) => e.raw), + ); + }); + + test("Q4: unticking it in the installed references and applying restarts the compiler again, and the new add-in no longer lists it", async () => { + await openReferences(c); + const r = await tick(c, "WebView2Package", { ticked: true, list: INSTALLED }); + assert.deepEqual(sorted(r.after), sorted(r.before.filter((s) => s !== "WebView2Package"))); + const applied = await apply(); + assert.deepEqual(applied.lines, RESTARTED); + assert.deepEqual(applied.boxes, [], "the IDE asked something"); + const ls = await loadsSince(); + assert.equal(ls.length, 1); + assert.notEqual(ls[0].pid, compiler); + assert.equal(ls[0].pid, await compilerPid(c)); + const es = phaseOf(ls[0], "load"); + assert.deepEqual(topNames(es, "ReferencesDefaults"), ["VB", "VBRUN", "VBA"]); + assert.equal(nestedNames(es, "WebView2Package"), undefined); + }); +});