diff --git a/.github/workflows/beta-static.yml b/.github/workflows/beta-static.yml new file mode 100644 index 00000000..b6ea15eb --- /dev/null +++ b/.github/workflows/beta-static.yml @@ -0,0 +1,82 @@ +# Deploy a configured beta branch from a fork to GitHub Pages. +name: Deploy beta to Pages + +on: + # Branch filtering is handled by deploy-check so + # BETA_BRANCH remains the single place to configure the branch for reuse. + push: + workflow_dispatch: + +env: + BETA_REPOSITORY: Josverl/viperide + BETA_BRANCH: feat/type_checking + VIPER_IDE_DEPLOYMENT_TAG: TYPING_BETA + +permissions: + contents: read + pages: write + id-token: write + +# GitHub Pages has one deployment target per repository. Use the same +# concurrency group as the production workflow so deployments cannot overlap. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy-check: + runs-on: ubuntu-latest + outputs: + should_deploy: ${{ steps.check_source.outputs.should_deploy }} + steps: + - name: Check repository and branch + id: check_source + shell: bash + run: | + if [[ "${GITHUB_REPOSITORY,,}" == "${BETA_REPOSITORY,,}" && + "$GITHUB_REF_TYPE" == "branch" && + "$GITHUB_REF_NAME" == "$BETA_BRANCH" ]]; then + echo "should_deploy=true" >> "$GITHUB_OUTPUT" + else + echo "should_deploy=false" >> "$GITHUB_OUTPUT" + fi + + deploy-beta: + needs: deploy-check + # The env context is unavailable in a job-level condition, so the validation + # job exposes the comparison as an output. + if: needs.deploy-check.outputs.should_deploy == 'true' + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + env: + # Project Pages is hosted below /. build.py substitutes + # this value into application URLs, the manifest, and generated HTML. + VIPER_IDE_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} + steps: + - name: Checkout beta branch + uses: actions/checkout@v7 + + # Match the production workflow: prepare dependencies once, then lint and + # test before creating the deployable build. + - name: Test + run: | + python3 build.py --prepare + npm run lint + npm run test + + - name: Build beta site + run: python3 build.py --skip-tests + + - name: Setup Pages + uses: actions/configure-pages@v6 + + - name: Upload beta site + uses: actions/upload-pages-artifact@v5 + with: + path: ./build + + - name: Deploy beta to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index c384afb8..617eacf1 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -22,3 +22,44 @@ jobs: - name: Build run: python3 build.py --skip-tests + + browser-test: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.55.0-noble + options: --user 1001 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + browser: [chromium] + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Prepare + run: python3 build.py --prepare + + - name: Build + run: python3 build.py --skip-tests + + - name: Verify Playwright version + run: test "$(npx playwright --version)" = "Version 1.55.0" + + - name: Run browser tests + run: npx playwright test --project "${{ matrix.browser }}" + + - name: Upload browser test failure artifacts + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-${{ matrix.browser }}-${{ github.run_attempt }} + path: | + results/playwright-report/ + results/playwright/**/trace.zip + if-no-files-found: ignore diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index b3d5a3c0..4f10d2ba 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -24,6 +24,8 @@ concurrency: jobs: # Single deploy job since we're just deploying deploy: + # Forks can provide their own Pages workflow without publishing main here. + if: github.repository == 'vshymanskyy/ViperIDE' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -53,7 +55,7 @@ jobs: uses: actions/upload-pages-artifact@v5 with: # Upload entire repository - path: './build' + path: "./build" - name: Deploy to GitHub Pages id: deployment diff --git a/.gitignore b/.gitignore index 3a39b630..dbad51d3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ node_modules/ extra/ src/tools_vfs/lib/python_minifier/ bun.lock +results/playwright +results/playwright-report \ No newline at end of file diff --git a/README.md b/README.md index 4b0694e6..6cae721b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,11 @@ - [mpy-cross-wasm](https://github.com/vshymanskyy/mpy-cross-wasm) - Code validation and `.mpy` compilation, MIT - [mpy-tool](https://github.com/micropython/micropython/blob/master/tools/mpy-tool.py) - MPY bytecode disassembler - MIT - [python-minifier](https://github.com/dflook/python-minifier) - Code minifier, MIT +- [@mp-typing/lsp-client](https://www.npmjs.com/package/@mp-typing/lsp-client) - LSP integration for CodeMirror 6, MIT +- [@mp-typing/pyright-worker](https://www.npmjs.com/package/@mp-typing/pyright-worker) - Web Worker providing Pyright and MicroPython stubs, MIT + - [pyright](https://www.npmjs.com/package/pyright) - Static type checker for Python, MIT + - [micropython-stubs](https://github.com/josverl/micropython-stubs) - MicroPython type stubs, MIT + ## Forks and derivative projects diff --git a/assets/viper-tools-stubs/viper_tools_stubs-0.1.2.0-py3-none-any.whl b/assets/viper-tools-stubs/viper_tools_stubs-0.1.2.0-py3-none-any.whl new file mode 100644 index 00000000..8d51094b Binary files /dev/null and b/assets/viper-tools-stubs/viper_tools_stubs-0.1.2.0-py3-none-any.whl differ diff --git a/build.py b/build.py index 92af073f..04279268 100755 --- a/build.py +++ b/build.py @@ -12,13 +12,16 @@ if not BASE_URL: BASE_URL = os.environ["VIPER_IDE_BASE_URL"] = "http://localhost:10001" + def run(cmd): subprocess.run(cmd, shell=isinstance(cmd, str), check=True) + def readfile(fn): - with open(fn, 'r', encoding='utf-8') as f: + with open(fn, "r", encoding="utf-8") as f: return f.read() + def remove_files(*filenames): for fn in filenames: try: @@ -26,26 +29,29 @@ def remove_files(*filenames): except FileNotFoundError: pass + def gen_translations(src, dst): result = {} - for fn in glob.glob('*.json', root_dir=src): - lang = fn.replace('.json', '') + for fn in glob.glob("*.json", root_dir=src): + lang = fn.replace(".json", "") result[lang] = json.loads(readfile(path.join(src, fn))) - with open(dst, 'w', encoding='utf-8') as f: - json.dump(result, f, separators=(',',':'), ensure_ascii=False, sort_keys=True) + with open(dst, "w", encoding="utf-8") as f: + json.dump(result, f, separators=(",", ":"), ensure_ascii=False, sort_keys=True) + def gen_manifest(src, dst): - pkg = json.loads(readfile('package.json')) + pkg = json.loads(readfile("package.json")) result = json.loads(readfile(src)) - result['version'] = pkg['version'] - with open(dst, 'w', encoding='utf-8') as f: - json.dump(result, f, separators=(',',':'), ensure_ascii=False) + result["version"] = pkg["version"] + with open(dst, "w", encoding="utf-8") as f: + json.dump(result, f, separators=(",", ":"), ensure_ascii=False) + def gen_tar(src, dst): def reset_tarinfo(tarinfo): # Stray bytecode caches must never reach the device image. Returning # None drops the entry, and for a directory also stops the recursion. - if '__pycache__' in tarinfo.name.split('/') or tarinfo.name.endswith('.pyc'): + if "__pycache__" in tarinfo.name.split("/") or tarinfo.name.endswith(".pyc"): return None tarinfo.uid = 0 tarinfo.gid = 0 @@ -53,34 +59,60 @@ def reset_tarinfo(tarinfo): tarinfo.gname = "" tarinfo.mtime = 0 return tarinfo - with open(dst, 'wb') as raw: - with gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=0) as gz: - with tarfile.open(fileobj=gz, mode='w') as tar: + + with open(dst, "wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz: + with tarfile.open(fileobj=gz, mode="w") as tar: for item in sorted(os.listdir(src)): item_path = os.path.join(src, item) tar.add(item_path, arcname=item, filter=reset_tarinfo) + def vendor_pypi_package(spec, dest): # --upgrade is required: without it pip silently skips an existing target # directory, so a stale vendored copy would never be replaced. - run([sys.executable, "-m", "pip", "install", "--target", dest, - "--no-compile", "--no-deps", "--upgrade", "--quiet", spec]) + run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + dest, + "--no-compile", + "--no-deps", + "--upgrade", + "--quiet", + spec, + ] + ) # pip also drops console scripts and metadata into the target; neither # belongs in the on-device filesystem image. rmtree(path.join(dest, "bin"), ignore_errors=True) for meta in glob.glob("*.dist-info", root_dir=dest): rmtree(path.join(dest, meta), ignore_errors=True) + def combine(dst): # Insert CSS and JS into HTML - combined = readfile(dst).replace( - '', '' - ).replace( - '', '' - ).replace( - '', '' - ).replace( - '', '' + combined = ( + readfile(dst) + .replace( + '', + "", + ) + .replace( + '', + "", + ) + .replace( + '', + "", + ) + .replace( + '', + "", + ) ) for asset in ("app.css", "viper_lib.css", "app.js", "viper_lib.js"): @@ -88,14 +120,22 @@ def combine(dst): raise Exception(f"{dst}: failed to inline {asset}") # Write the combined content - with open(dst, 'w', encoding='utf-8') as f: + with open(dst, "w", encoding="utf-8") as f: f.write(combined) + if __name__ == "__main__": import argparse + parser = argparse.ArgumentParser(description="Build the VIPER IDE") - parser.add_argument("--skip-tests", action="store_true", help="Skip linting and tests") - parser.add_argument("--prepare", action="store_true", help="Only vendor dependencies (for running tests without a full build)") + parser.add_argument( + "--skip-tests", action="store_true", help="Skip linting and tests" + ) + parser.add_argument( + "--prepare", + action="store_true", + help="Only vendor dependencies (for running tests without a full build)", + ) args = parser.parse_args() # Prepare @@ -106,7 +146,25 @@ def combine(dst): gen_translations("./src/lang/", "build/translations.json") gen_manifest("./src/manifest.json", "build/manifest.json") + rmtree("src/tools_vfs/lib/python_minifier", ignore_errors=True) vendor_pypi_package("python-minifier==3.2.0", "src/tools_vfs/lib") + # CPython permits starred arguments after keywords, but MicroPython does + # not. Keep this narrow compatibility rewrite until upstream supports it. + ast_compat = "src/tools_vfs/lib/python_minifier/ast_compat.py" + source = readfile(ast_compat) + replacements = { + "Constant(value=s, *args, **kwargs)": "Constant(*args, value=s, **kwargs)", + "Constant(value=n, *args, **kwargs)": "Constant(*args, value=n, **kwargs)", + "Constant(value=literal_eval('...'), *args, **kwargs)": "Constant(*args, value=literal_eval('...'), **kwargs)", + } + for old, new in replacements.items(): + if old not in source: + raise RuntimeError( + f"Expected python-minifier compatibility pattern missing: {old}" + ) + source = source.replace(old, new) + with open(ast_compat, "w", encoding="utf-8") as f: + f.write(source) gen_tar("src/tools_vfs", "build/assets/tools_vfs.tar.gz") gen_tar("src/vm_vfs", "build/assets/vm_vfs.tar.gz") @@ -130,18 +188,24 @@ def combine(dst): combine("build/benchmark.html") # Cleanup - #remove_files("build/translations.json") + # remove_files("build/translations.json") remove_files("build/app.css", "build/viper_lib.css") remove_files("build/app.js", "build/viper_lib.js") # Add assets from packages - cp("node_modules/@micropython/micropython-webassembly-pyscript/micropython.wasm", "./build/assets/micropython.wasm") + cp( + "node_modules/@micropython/micropython-webassembly-pyscript/micropython.wasm", + "./build/assets/micropython.wasm", + ) # mpy-cross ships one binary per .mpy ABI; python_utils.js picks the one the # connected board can import, so all of them have to be served. mpy_cross = "node_modules/@vshymanskyy/mpy-cross-wasm/build" for wasm in sorted(glob.glob("mpy-cross-v*.wasm", root_dir=mpy_cross)): cp(path.join(mpy_cross, wasm), f"./build/assets/{wasm}") - cp("node_modules/@astral-sh/ruff-wasm-web/ruff_wasm_bg.wasm", "./build/assets/ruff_wasm_bg.wasm") + cp( + "node_modules/@astral-sh/ruff-wasm-web/ruff_wasm_bg.wasm", + "./build/assets/ruff_wasm_bg.wasm", + ) print() print("Build complete.") diff --git a/docs/Advanced-Mode.md b/docs/Advanced-Mode.md index 7cfff390..1cdfffac 100644 --- a/docs/Advanced-Mode.md +++ b/docs/Advanced-Mode.md @@ -4,3 +4,8 @@ Some features that are rarely used, can be mesleading or annoying are disabled b - Dissector and disassembler for `.mpy` files (instead of default `hex` view) - `sysinfo.md` - a virtual file that collects some useful info from the board +- Type checking + - Selected type stub package details + - Option to include Viper Tools stubs in type checking + - Install type stub packages from PyPI + - Clear installed stub packages diff --git a/docs/Development.md b/docs/Development.md index aa4663cc..c524e9dc 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -22,6 +22,7 @@ npm install --include=dev | `docs/` | User and contributor documentation | | `packages/viper-tools/` | MicroPython helper package metadata and files | | `mcp/` | MCP server for controlling ViperIDE from an AI client | +| `src/typechecking/` | Application-owned adapter around the reusable type-checking packages (see [Type-Checking](Type-Checking.md)) | | `build.py` | Production build script used by GitHub Pages deployment | | `rollup.config.mjs` | Rollup bundle configuration | @@ -78,6 +79,77 @@ Start the watcher: npm start ``` +### Build with local CodeMirror packages + +To test changes from a local `stubs_playground` checkout without changing +ViperIDE's registry dependencies or lockfile, pass its path to `build:local`: + +```sh +npm run build:local -- ../stubs_playground +``` + +Build and start the local development server in one command: + +```sh +npm run start:local -- ../stubs_playground +``` + +Open . Rollup watches ViperIDE source files; stop +the server with Ctrl+C. Restart the command after changing package sources so +the local Pyright worker is rebuilt. + +Build with the local packages and run the full Playwright browser suite: + +```sh +npm run test:local -- ../stubs_playground +``` + +The path may be absolute or relative to the directory where npm is invoked. It +may identify the workspace root, its `packages` directory, either package +directory, or either package's `package.json`. The command builds the local +Pyright worker in development mode, bundles the local LSP client source, and +copies the local worker assets into ViperIDE's `build/` directory. +`test:local` runs the Chromium Playwright suite against that generated build. + +Normal `npm run build` builds against the registry versions installed in +`node_modules`. + +### Client-owned type-stub overlays + +ViperIDE owns the release lifecycle of stubs for modules bundled with +ViperIDE, including `viper-tools-stubs`. + +| ViperIDE release process | Reusable type-checking backend | +|---|---| +| Selects and obtains the type-only wheel. | Defines the generic `extraStubArchives` contract. | +| Vendors or publishes the archive. | Forwards host-provided archive metadata. | +| Computes and supplies its byte size, SHA-256, URL/data, and allowed origins. | Validates integrity and rejects unsafe or non-type-only content. | +| Owns the user setting, default, restart behavior, errors, and update cadence. | Mounts accepted stubs under `/extra/` and configures Pyright. | + +The backend does not build, publish, select, or bundle ViperIDE wheels. Its npm +artifacts and runtime manifest remain client-neutral. Adding or updating a +ViperIDE overlay changes ViperIDE's assets and configuration only; it does not +require rebuilding or releasing the worker. + +The standalone `viper-tools-stubs` project produces a normal wheel with +`uv build`. ViperIDE's release process is responsible for turning the selected +wheel into a deployed, integrity-described asset and passing it through +`extraStubArchives`. + +## Browser tests + +The Playwright tests in `test/browser/` need a current `build/` directory. +Install Chromium once, then run the suite against the current build: + +```sh +npx playwright install chromium +npm run test:browser +``` + +Use `npm run test:browser:ui` for Playwright's interactive runner. The suite is +configured for Chromium and starts a local static server for `build/` on port +10001. If the build is missing or stale, run `npm run build` first. + ## Linting diff --git a/docs/Type-Checking.md b/docs/Type-Checking.md new file mode 100644 index 00000000..de386b34 --- /dev/null +++ b/docs/Type-Checking.md @@ -0,0 +1,264 @@ +# Type-Checking Integration + +This document describes ViperIDE's application-owned type-checking integration +for maintainers reviewing or extending the feature. It covers the public +integration points ViperIDE exposes around the reusable +[`@mp-typing/lsp-client`](https://www.npmjs.com/package/@mp-typing/lsp-client) +and [`@mp-typing/pyright-worker`](https://www.npmjs.com/package/@mp-typing/pyright-worker) +packages. The reusable client, worker, transport, and LSP plugin APIs are +documented with those packages; this document does not duplicate them and +instead explains how ViperIDE consumes them. + +## Ownership boundary + +ViperIDE owns everything specific to the product: the settings UI, editor and +device lifecycle, the mirrored Python workspace, board selection, diagnostics +and status presentation, and the bundled Viper tools stubs. The reusable +packages own the worker runtime, JSON-RPC/LSP protocol, CodeMirror plugin, and +worker transport control protocol. + +The single seam between the two is `TypecheckingService`, an +application-level adapter that holds one worker runtime and injects +ViperIDE-supplied hooks into the reusable client. The service contains no DOM +dependencies; hosts inject editor and runtime callbacks. + +## Module layout + +| Module | Responsibility | +|---|---| +| `src/typechecking/typechecking.js` | Wires the reusable client factories into a single shared `TypecheckingService` instance and exposes the stub manifest loader. | +| `src/typechecking/typechecking_service.js` | The integration adapter: runtime lifecycle, editor bindings, workspace mirror, board switching, stub-package management, and status snapshots. | +| `src/typechecking/typechecking_assets.js` | Resolves worker/runtime/stub asset URLs from the copied npm package and bundled Viper tools wheel. | +| `src/typechecking/typechecking_settings.js` | Pure functions that normalize ViperIDE settings into reusable-client runtime config and catalog selections. | +| `src/typechecking/typechecking_status.js` | Pure functions that turn a service snapshot into status text and diagnostic summaries. | +| `src/typechecking/typechecking_workspace.js` | Reads device Python files and reconciles them into the workspace mirror. | + +The shared instance is constructed once in `typechecking.js`: + +```js +export const typechecking = new TypecheckingService({ + createLSPClient, + createLSPPlugin, + notifyDocumentChange, + notifyDocumentClose, + switchBoard, + prepareRuntime: config => typecheckingAssets.prepare(config), +}) +``` + +The application binds its editor integration once at startup: + +```js +typechecking.setEditorIntegration(configureTypechecking) +``` + +## Lifecycle + +`TypecheckingService` moves through a small set of statuses. Every transition is +published to `onStatusChange` listeners. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> starting: initialize() + starting --> ready: worker handshake ok + starting --> error: preparation / startup failed + ready --> switching: selectStubBundle() + switching --> ready: board replaced + switching --> error: switch failed + ready --> disabled: disable() + disabled --> starting: initialize() + ready --> disposed: dispose() + error --> starting: initialize() + disposed --> [*] +``` + +- `initialize(config)` starts the worker and LSP handshake. Concurrent calls are + coalesced, and a call while `ready` resolves immediately with the current + snapshot. `config` is passed through `prepareRuntime` to resolve the worker + URL and selected stub bundle. +- `disable()` stops type checking but retains editor bindings and mirrored files + so the service can be re-initialized cheaply. +- `restartRuntime(configOverrides)` replaces the runtime while preserving + configuration and editor bindings. It is used after installing or clearing + stub packages. +- `dispose()` permanently closes the service and releases worker resources and + listeners. ViperIDE calls this only on real page unload, not on bfcache + `pagehide`, so a restored page keeps its worker. + +A monotonic generation guard prevents a late worker handshake from surviving +disposal or re-initialization. + +## Editor binding + +Editors are bound as tabs load and unbound as they close. Binding is safe before +initialization: the document is opened when the runtime becomes ready. + +```js +document.addEventListener('editorLoaded', event => { + if (!supportsTypechecking(event.detail.fn, event.detail.editor.state.readOnly)) return + typechecking.bindEditor(event.detail.editor, event.detail.fn) + .catch(err => report('Unable to enable type checking for this file', err)) +}) + +document.addEventListener('tabClosed', event => { + const closed = getEditorFromElement(event.detail.editorElement) + if (closed) typechecking.unbindEditor(closed) +}) +``` + +- `bindEditor(editorView, path)` mirrors the current buffer, records the binding, + and — when ready — opens the LSP document and installs the CodeMirror + extensions through the host `configureEditor` callback. It returns the encoded + `file:///workspace/...` URI. +- `changeEditor(editorView, content)` publishes the complete current document to + Pyright. ViperIDE calls it from the editor update handler so completion and + hover always see current text. +- `unbindEditor(editorView)` closes the LSP document and removes the editor + extensions. + +Document URIs live below `file:///workspace`. Paths are validated and rejected +if they contain empty, `.`, or `..` segments. + +## Device and workspace synchronization + +Filesystem events keep the mirror aligned with the file tree: + +```js +document.addEventListener('fileRenamed', e => typechecking.renamePath(e.detail.old, e.detail.new)) +document.addEventListener('fileRemoved', e => typechecking.removePath(e.detail.path)) +document.addEventListener('dirRemoved', e => typechecking.removePath(e.detail.path, true)) +``` + +- `renamePath` / `removePath` update the mirror and any open editors. +- `hydrateWorkspace(files)` merges `.py` files into the mirror without removing + anything. +- `replaceWorkspace(files, { preservePaths })` reconciles the mirror to a + complete snapshot. Open editor buffers (including unsaved edits) always win + over device content, and `preservePaths` protects files that could not be read + during an incomplete device read. + +When workspace-scope diagnostics are enabled, ViperIDE reads the connected +device's Python files and reconciles them through `replaceWorkspace`: + +```js +await syncDevicePythonWorkspace({ + enabled: getSetting('typecheck-enabled'), + scope: getSetting('typecheck-scope'), + raw, fsCache, isSpecialPath, + replaceWorkspace: (files, options) => typechecking.replaceWorkspace(files, options), +}) +``` + +Device reads are sequential because raw-mode commands cannot overlap, and +unreadable files are preserved rather than dropped. + +Board selection is driven by device metadata. On `deviceConnected`, ViperIDE +queues a selection that resolves the target from `sys.platform` (MicroPython) or +descriptive identity fields (CircuitPython): + +- `selectDevice(devInfo)` selects stubs inferred from device metadata. +- `selectStubBundle(boardId)` restarts Pyright with a specific manifest bundle, + rebinding existing editors and mirrored files. + +Type checking is intentionally independent of the device transport and stays +alive across reconnects. + +## Settings and reconfiguration + +`typechecking_settings.js` holds pure conversion functions and does not touch the +service. ViperIDE settings map to reusable-client runtime config through: + +- `typecheckingRuntimeConfig(settings)` and + `catalogTypecheckingRuntimeConfig(settings)` — build `extraPaths`, + `diagnosticMode`, `typeCheckingMode`, `typeshedPath`, and the board/stub + package selection. ViperIDE always sets `typeshedPath` to + `/typeshed-micropython` so the client uses MicroPython stdlib stubs instead of + CPython typeshed. +- `normalizeTypecheckingMode` / `normalizeTypecheckingScope` / + `normalizeTypecheckingBoard` — clamp persisted values to supported sets. +- `resolveTypecheckingBoard(board, devInfo)` — resolve an `auto` board to the + connected device target. +- `typecheckingStubPreferences` / `typecheckingAutodetectFallback` — map device + metadata to catalog family/port/version/board and surface autodetect warnings. +- `parseStubPackageSpecifier(value)` — validate a PyPI name plus optional version + constraint before installation. + +Reconfiguration (mode, scope, board, or device change) restarts the runtime with +`restartRuntime(currentTypecheckingConfig())` when ready, or `initialize` when +not. Application code serializes these operations so overlapping settings changes +apply in order. + +## Diagnostics and status access + +The service exposes state only through immutable snapshots. + +- `onStatusChange(listener)` subscribes to lifecycle, diagnostics, and workspace + snapshots and invokes the listener immediately. It returns an unsubscribe + callback. +- `snapshot()` returns the current `TypecheckingSnapshot` with copied `Map` + containers. Diagnostic objects and the `client`/`transport` references are + shared and must be treated as read-only. + +`typechecking_status.js` derives presentation from a snapshot: + +- `collectDiagnosticEntries(diagnosticStatus)` flattens and deduplicates + Pyright diagnostics. +- `typecheckingStatusPresentation(snapshot, enabled)` and + `renderTypecheckingStatus(...)` produce the status label, tooltip, and busy + state, including the runtime source (remote, cached last-known-good, or + bundled) and count of skipped incompatible runtimes. + +The diagnostics panel separately reads CodeMirror's merged lint state so it can +combine Pyright with Ruff and mpy-cross results; the service snapshot carries the +Pyright contribution only. + +## Package and asset loading + +`TypecheckingAssets.prepare(config)` resolves the worker URL, runtime manifest +options, board stub bundle, and any extra stub archives from the worker package +assets copied into the ViperIDE build. Key behavior: + +- The copied npm worker URL is always supplied as the bundled offline fallback. + When a `runtimeManifestUrl` is configured, the reusable client selects a + compatible immutable runtime at startup and falls back to the bundle when none + is compatible. +- The bundled Viper tools wheel is injected as an `extraStubArchives` entry when + `viperToolsStubs` is enabled. Its filename, size, and SHA-256 are build-time + constants; the archive is restricted to its own origin. +- `loadManifest()` loads and memoizes the stub manifest; failed loads are not + cached and may be retried. + +Runtime stub packages can also be installed from PyPI at runtime: + +- `installStubPackage(name, versionSpecifier)` installs a wheel and restarts + Pyright automatically. +- `clearStubPackages(name?, version?)` clears cached stubs and restarts only when + the worker reports a restart is required. +- `listStubPackages`, `getStubPackageCatalog`, and `listInstalledStubPackages` + are read-only catalog queries; they wait for and retry across an in-progress + runtime replacement rather than failing. + +## Failure behavior + +- Preparation, worker startup, or editor rebinding failures move the service to + `error` with the originating error retained in the snapshot. The status tooltip + instructs the user to toggle type checking in Settings to retry, and + re-initialization is permitted from `error`. +- Board switch failures close the runtime and surface as `error`. +- Editor bindings that the host `configureEditor` callback rejects are removed, + and `bindEditor` / `rebindEditor` throw so the caller can report which files + could not be type-checked. +- Device workspace read errors are logged per file and the affected paths are + preserved in the existing mirror; a single unreadable file does not abort the + sync. +- Stub-package management requires a ready runtime; queries throw a clear error + when the service is not ready. + +## Related documentation + +- Reusable client, worker, transport, and CodeMirror plugin APIs: + `@mp-typing/lsp-client` and `@mp-typing/pyright-worker` package READMEs. +- ViperIDE local development workflow: [Development](Development.md). +- Advanced-mode settings that expose stub-package management: + [Advanced Mode](Advanced-Mode.md). diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c92c5d8..d278bcfa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,9 @@ export default [ VIPER_IDE_VERSION: "readonly", VIPER_IDE_BUILD: "readonly", VIPER_IDE_BASE_URL: "readonly", + VIPER_TOOLS_STUBS_FILENAME: "readonly", + VIPER_TOOLS_STUBS_SIZE: "readonly", + VIPER_TOOLS_STUBS_SHA256: "readonly", } } } diff --git a/mcp/src/index.js b/mcp/src/index.js index b3e44616..dc4ed381 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -297,7 +297,7 @@ mcp.tool( const wsUrl = `ws://localhost:${idePort}/serial/${encodeURIComponent(port_path)}` // Fire and forget - connection + raw mode handshake can take 20+ seconds // which exceeds Claude Desktop's API timeout. Return immediately. - bridge.call('connect_device', { type: 'ws', url: wsUrl }) + bridge.call('connect_device', { type: 'ws', url: wsUrl, password: 'serial' }) .catch(err => console.error('[ViperIDE MCP] connect_serial error:', err.message)) return textResult({ url: wsUrl, diff --git a/mcp/test_mcp_client.js b/mcp/test_mcp_client.js new file mode 100644 index 00000000..a6b27335 --- /dev/null +++ b/mcp/test_mcp_client.js @@ -0,0 +1,121 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import fs from "fs"; +import path from "path"; +import http from "http"; + +const mcpJsonPath = "/home/jos/stubs_playground/.vscode/mcp.json"; +const mcpJsonRaw = fs.readFileSync(mcpJsonPath, "utf-8"); +console.log("mcp.json parses successfully:", !!JSON.parse(mcpJsonRaw)); + +const mcpConfig = JSON.parse(mcpJsonRaw); +const serverConfig = mcpConfig.servers.viperIDE; + +// Substitute workspaceFolder:stubs_playground with /home/jos/stubs_playground +const workspaceSub = "/home/jos/stubs_playground"; + +const command = serverConfig.command; +const args = serverConfig.args.map(arg => arg.replace(/\$\{workspaceFolder:stubs_playground\}/g, workspaceSub)); +const env = {}; +for (const [key, val] of Object.entries(serverConfig.env || {})) { + env[key] = val.replace(/\$\{workspaceFolder:stubs_playground\}/g, workspaceSub); +} +// Merge process env too +Object.assign(env, process.env); + +console.log("Command details:"); +console.log("- Command:", command); +console.log("- Args:", args); +console.log("- Env VIPERIDE_BUILD_DIR:", env.VIPERIDE_BUILD_DIR); + +const transport = new StdioClientTransport({ + command: command, + args: args, + env: env +}); + +const client = new Client({ + name: "test-client", + version: "1.0.0" +}, { + capabilities: {} +}); + +async function run() { + console.log("Connecting to standard I/O MCP server..."); + await client.connect(transport); + console.log("Connected successfully!"); + + // Call the viperIDE_get_status tool or tool listing to find the call. + // First list tools: + const tools = await client.listTools(); + console.log("Tools available:", tools.tools.map(t => t.name)); + + console.log("Calling viperIDE_get_status..."); + const result = await client.callTool({ + name: "viperIDE_get_status", + arguments: {} + }); + + console.log("Result content:", JSON.stringify(result, null, 2)); + + // Find ideUrl from result + let ideUrl = null; + if (result.content && Array.isArray(result.content)) { + for (const c of result.content) { + if (c.type === "text" && c.text) { + // Try parsing JSON if the output is JSON + try { + const parsed = JSON.parse(c.text); + if (parsed.ideUrl) { + ideUrl = parsed.ideUrl; + } + } catch (e) { + // Check if string contains ideUrl + const match = c.text.match(/ideUrl["']?\s*:\s*["']([^"']+)["']/); + if (match) { + ideUrl = match[1]; + } + } + } + } + } + + if (!ideUrl) { + throw new Error("Could not find ideUrl from viperIDE_get_status result."); + } + + console.log("Extracted ideUrl:", ideUrl); + + // Make http request to ideUrl and check if it serves HTML + console.log("Making HTTP request to:", ideUrl); + await new Promise((resolve, reject) => { + http.get(ideUrl, (res) => { + console.log(`HTTP Status Code: ${res.statusCode}`); + console.log(`HTTP Headers:`, res.headers); + let data = ""; + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => { + const containsHTML = data.toLowerCase().includes("") || data.toLowerCase().includes(" { + reject(err); + }); + }); + + console.log("All validated correctly. Closing client..."); + await client.close(); + console.log("Client closed."); +} + +run().catch(err => { + console.error("Error running test:", err); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index d7694d29..ce41eb77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@gera2ld/tarjs": "^0.3.1", - "@micropython/micropython-webassembly-pyscript": "1.27.0", + "@micropython/micropython-webassembly-pyscript": "^1.29.0-6", + "@mp-typing/lsp-client": "0.3.5", + "@mp-typing/pyright-worker": "0.4.5", "@uiw/codemirror-theme-material": "^4.25.11", "@uiw/codemirror-theme-monokai": "^4.25.11", "@vshymanskyy/mpy-cross-wasm": "https://github.com/vshymanskyy/mpy-cross-wasm/releases/download/v1.1.0/vshymanskyy-mpy-cross-wasm-1.1.0.tgz", @@ -38,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "1.55.0", "@rollup/plugin-commonjs": "^29.0.3", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", @@ -46,6 +49,7 @@ "chai": "^6.2.2", "eslint": "^10.8.1", "globals": "^17.9.0", + "http-server": "^14.1.1", "mocha": "^11.8.0", "rollup": "^4.62.4", "rollup-plugin-import-css": "^4.2.1", @@ -767,9 +771,27 @@ } }, "node_modules/@micropython/micropython-webassembly-pyscript": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@micropython/micropython-webassembly-pyscript/-/micropython-webassembly-pyscript-1.27.0.tgz", - "integrity": "sha512-DnXx4EqxgkeaFESrS3uKrL7mLMk4mRTd0mMzaOd3HUSiZUOAE7t11uLnjf3ftF87N2xm4knHnWEKcVK4IYIUDA==", + "version": "1.29.0-6", + "resolved": "https://registry.npmjs.org/@micropython/micropython-webassembly-pyscript/-/micropython-webassembly-pyscript-1.29.0-6.tgz", + "integrity": "sha512-kLwGPJFsLY/2aUPmzZxpvauFSmKc0SlJWVvvEttULR0poVQ6W9HV//1fUJyxrGWAxCVDw9aYqJ0LAog0ZLjYVA==", + "license": "MIT" + }, + "node_modules/@mp-typing/lsp-client": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@mp-typing/lsp-client/-/lsp-client-0.3.5.tgz", + "integrity": "sha512-bUCS04s4BMVpTw9bGVHnowJ3HFQQuUKjEV6bx2ybSsY7sdkS4wIpkRSqXHOHQYlgggdTQUpnBYjPyZ2hIk8kYA==", + "license": "MIT", + "peerDependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/@mp-typing/pyright-worker": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@mp-typing/pyright-worker/-/pyright-worker-0.4.5.tgz", + "integrity": "sha512-shNMm/YHavuBsSHVSPUqgpWuMTzeUJj9oER0P018XUqkM/zMblbXSGvY9wGcVlV53vzkRJenAhWXYXLY6MlVtA==", "license": "MIT" }, "node_modules/@msgpack/msgpack": { @@ -811,6 +833,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "29.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", @@ -1776,6 +1814,13 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1786,6 +1831,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", @@ -1812,6 +1877,37 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -2000,6 +2096,16 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -2096,6 +2202,21 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2110,6 +2231,39 @@ "dev": true, "license": "MIT" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2395,6 +2549,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2445,6 +2620,45 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/github-fork-ribbon-css": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/github-fork-ribbon-css/-/github-fork-ribbon-css-0.2.3.tgz", @@ -2531,6 +2745,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2541,6 +2768,19 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2563,6 +2803,75 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/i18next": { "version": "26.3.6", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", @@ -2600,6 +2909,19 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2892,6 +3214,16 @@ "node": ">= 20" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mime": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.6.tgz", @@ -2923,6 +3255,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -3049,6 +3391,19 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -3209,6 +3564,67 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3229,6 +3645,23 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3263,6 +3696,13 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3401,11 +3841,25 @@ "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sdp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.0.tgz", "integrity": "sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==" }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, "node_modules/serialize-javascript": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", @@ -3486,6 +3940,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -3775,6 +4305,18 @@ "node": "*" } }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3785,6 +4327,13 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -3813,6 +4362,20 @@ "npm": ">=3.10.0" } }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 21a7f7ee..cac5f3e0 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,15 @@ "type": "module", "scripts": { "build": "rollup --config", + "build:local": "node scripts/build-local.mjs build", "start": "rollup --config --configDebug --watch", + "start:local": "node scripts/build-local.mjs start", "lint": "npx eslint", - "test": "npx mocha" + "test": "npx mocha && npm run test:unit", + "test:browser": "playwright test", + "test:browser:ui": "playwright test --ui", + "test:local": "node scripts/build-local.mjs test", + "test:unit": "npx mocha --no-config \"test/unit/**/*.js\"" }, "dependencies": { "@amplitude/analytics-browser": "^2.45.5", @@ -22,7 +28,9 @@ "@fortawesome/free-regular-svg-icons": "^7.3.1", "@fortawesome/free-solid-svg-icons": "^7.3.1", "@gera2ld/tarjs": "^0.3.1", - "@micropython/micropython-webassembly-pyscript": "1.27.0", + "@micropython/micropython-webassembly-pyscript": "^1.29.0-6", + "@mp-typing/lsp-client": "0.3.5", + "@mp-typing/pyright-worker": "0.4.5", "@uiw/codemirror-theme-material": "^4.25.11", "@uiw/codemirror-theme-monokai": "^4.25.11", "@vshymanskyy/mpy-cross-wasm": "https://github.com/vshymanskyy/mpy-cross-wasm/releases/download/v1.1.0/vshymanskyy-mpy-cross-wasm-1.1.0.tgz", @@ -41,6 +49,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "1.55.0", "@rollup/plugin-commonjs": "^29.0.3", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", @@ -49,6 +58,7 @@ "chai": "^6.2.2", "eslint": "^10.8.1", "globals": "^17.9.0", + "http-server": "^14.1.1", "mocha": "^11.8.0", "rollup": "^4.62.4", "rollup-plugin-import-css": "^4.2.1", diff --git a/packages/viper-tools/ble_repl.py b/packages/viper-tools/ble_repl.py index c77ab7fb..d5efac4a 100644 --- a/packages/viper-tools/ble_repl.py +++ b/packages/viper-tools/ble_repl.py @@ -73,9 +73,9 @@ def write(self, buf): schedule_in(self._flush, 30) -def start(): +def start(name="mpy-repl"): ble = bluetooth.BLE() - uart = ble_nus.BLEUART(ble, name="mpy-repl") + uart = ble_nus.BLEUART(ble, name=name) stream = BLEUARTStream(uart) os.dupterm(stream) diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 00000000..6aaabe26 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = "http://localhost:10001"; + +export default defineConfig({ + testDir: "test/browser", + testMatch: "**/*.spec.mjs", + workers: 1, + timeout: 180_000, + expect: { + timeout: 5_000, + }, + globalSetup: "./test/browser/global-setup.mjs", + outputDir: "results/playwright", + reporter: [["html", { outputFolder: "results/playwright-report", open: "never" }]], + use: { + baseURL, + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npx --no-install http-server build -p 10001 -c-1 --silent", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); \ No newline at end of file diff --git a/results/typechecking-pyscript-autodetect.png b/results/typechecking-pyscript-autodetect.png new file mode 100644 index 00000000..479520b2 Binary files /dev/null and b/results/typechecking-pyscript-autodetect.png differ diff --git a/rollup.config.mjs b/rollup.config.mjs index 9868e5f3..ee8474a2 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -6,17 +6,86 @@ import terser from '@rollup/plugin-terser' import css from 'rollup-plugin-import-css' import serve from 'rollup-plugin-serve' import sourcemaps from 'rollup-plugin-sourcemaps2'; +import { createHash } from 'node:crypto' import fs from 'fs' +import path from 'path' const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')) // build.py passes this via the environment. When running Rollup directly, // default to the local development server. const BASE_URL = process.env.VIPER_IDE_BASE_URL || 'http://localhost:10001' +const DEPLOYMENT_TAG = process.env.VIPER_IDE_DEPLOYMENT_TAG || '' +// Normal builds use the installed packages. build:local supplies absolute source paths +// without changing package.json, package-lock.json, or node_modules. +const LSP_CLIENT_PACKAGE = process.env.VIPER_IDE_LOCAL_LSP_CLIENT_PACKAGE || '' +const PYRIGHT_WORKER_PACKAGE = process.env.VIPER_IDE_LOCAL_PYRIGHT_WORKER_PACKAGE || + 'node_modules/@mp-typing/pyright-worker' +const PYRIGHT_WORKER_BUILD = 'build/assets/pyright-worker' +const MPY_PACKAGE = 'node_modules/@micropython/micropython-webassembly-pyscript' +const MPY_WASM_BUILD = 'build/assets/micropython.wasm' +const VIPER_TOOLS_STUBS_SOURCE = 'assets/viper-tools-stubs' +const VIPER_TOOLS_STUBS_BUILD = 'build/assets/viper-tools-stubs' +let viperToolsStubs + +const copyPyrightWorkerPackage = () => { + fs.rmSync(PYRIGHT_WORKER_BUILD, { recursive: true, force: true }) + fs.mkdirSync(PYRIGHT_WORKER_BUILD, { recursive: true }) + for (const directory of ['assets', 'dist']) { + fs.cpSync( + `${PYRIGHT_WORKER_PACKAGE}/${directory}`, + `${PYRIGHT_WORKER_BUILD}/${directory}`, + { recursive: true }, + ) + } +} + +const copyMicroPythonWasm = () => { + fs.copyFileSync(`${MPY_PACKAGE}/micropython.wasm`, MPY_WASM_BUILD) +} + +const copyViperToolsStubs = () => { + const wheels = fs.readdirSync(VIPER_TOOLS_STUBS_SOURCE). + filter(filename => filename.endsWith('.whl')) + if (wheels.length !== 1) { + throw new Error(`${VIPER_TOOLS_STUBS_SOURCE}: expected exactly one wheel`) + } + + const filename = wheels[0] + const wheelPath = path.join(VIPER_TOOLS_STUBS_SOURCE, filename) + const wheel = fs.readFileSync(wheelPath) + viperToolsStubs = { + filename, + size: wheel.byteLength, + sha256: createHash('sha256').update(wheel).digest('hex'), + } + + fs.rmSync(VIPER_TOOLS_STUBS_BUILD, { recursive: true, force: true }) + fs.mkdirSync(VIPER_TOOLS_STUBS_BUILD, { recursive: true }) + fs.copyFileSync(wheelPath, path.join(VIPER_TOOLS_STUBS_BUILD, filename)) +} + +const localLspClient = () => { + if (!LSP_CLIENT_PACKAGE) { return null } + const packageJson = JSON.parse(fs.readFileSync(path.join(LSP_CLIENT_PACKAGE, 'package.json'), 'utf8')) + const entry = packageJson.exports?.['.']?.import || packageJson.browserDistribution?.entry || packageJson.main + if (packageJson.name !== '@mp-typing/lsp-client' || !entry) { + throw new Error(`${LSP_CLIENT_PACKAGE} is not an @mp-typing/lsp-client package`) + } + return { + name: 'local-lsp-client', + resolveId(source) { + return source === '@mp-typing/lsp-client' + ? path.resolve(LSP_CLIENT_PACKAGE, entry) + : null + }, + } +} const copyHtml = (src, dst) => { let data = fs.readFileSync(src, 'utf8'). replaceAll('${VIPER_IDE_BASE_URL}', BASE_URL). + replaceAll('${VIPER_IDE_DEPLOYMENT_TAG}', DEPLOYMENT_TAG). replaceAll('${VIPER_IDE_DESCR}', pkg.description) fs.writeFileSync(dst, data) } @@ -74,11 +143,19 @@ const common = (args, name) => ({ }, plugins: [ stripMicroPythonNodeCli(), + localLspClient(), css({ output: `${name}.css`, minify: !args.configDebug, }), - resolve(), + resolve({ + dedupe: [ + '@codemirror/autocomplete', + '@codemirror/lint', + '@codemirror/state', + '@codemirror/view', + ], + }), commonjs(), json({ compact: true @@ -89,6 +166,9 @@ const common = (args, name) => ({ VIPER_IDE_VERSION: '"' + pkg.version + '"', VIPER_IDE_BUILD: Date.now(), VIPER_IDE_BASE_URL: '"' + BASE_URL + '"', + VIPER_TOOLS_STUBS_FILENAME: JSON.stringify(viperToolsStubs.filename), + VIPER_TOOLS_STUBS_SIZE: String(viperToolsStubs.size), + VIPER_TOOLS_STUBS_SHA256: JSON.stringify(viperToolsStubs.sha256), } }), args.configDebug && sourcemaps(), @@ -101,13 +181,18 @@ const common = (args, name) => ({ ] }) -export default args => [{ - input: './src/app.js', - ...common(args, 'app') -},{ - input: './src/viper_lib.js', - ...common(args, 'viper_lib') -},{ - input: './src/app_worker.js', - ...common(args, 'app_worker') -}] +export default args => { + copyPyrightWorkerPackage() + copyMicroPythonWasm() + copyViperToolsStubs() + return [{ + input: './src/app.js', + ...common(args, 'app') + },{ + input: './src/viper_lib.js', + ...common(args, 'viper_lib') + },{ + input: './src/app_worker.js', + ...common(args, 'app_worker') + }] +} diff --git a/scripts/build-local.mjs b/scripts/build-local.mjs new file mode 100644 index 00000000..4368d261 --- /dev/null +++ b/scripts/build-local.mjs @@ -0,0 +1,126 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { spawnSync } from 'node:child_process' + +const PACKAGE_NAMES = Object.freeze({ + lspClient: '@mp-typing/lsp-client', + pyrightWorker: '@mp-typing/pyright-worker', +}) +const MODES = new Set(['build', 'start', 'test']) + +function packageName(directory) { + const manifest = join(directory, 'package.json') + if (!existsSync(manifest)) { return '' } + return JSON.parse(readFileSync(manifest, 'utf8')).name || '' +} + +function inputDirectory(input, baseDirectory) { + const absolute = resolve(baseDirectory, input) + return existsSync(absolute) && statSync(absolute).isFile() ? dirname(absolute) : absolute +} + +export function discoverLocalPackages(input, baseDirectory = process.cwd()) { + const directory = inputDirectory(input, baseDirectory) + const candidates = [ + directory, + join(directory, 'packages'), + dirname(directory), + ] + + for (const packagesDirectory of candidates) { + const directName = packageName(directory) + const lspClient = directName === PACKAGE_NAMES.lspClient + ? directory + : join(packagesDirectory, 'lsp-client') + const pyrightWorker = directName === PACKAGE_NAMES.pyrightWorker + ? directory + : join(packagesDirectory, 'pyright-worker') + const siblingLspClient = directName === PACKAGE_NAMES.pyrightWorker + ? join(dirname(directory), 'lsp-client') + : lspClient + const siblingPyrightWorker = directName === PACKAGE_NAMES.lspClient + ? join(dirname(directory), 'pyright-worker') + : pyrightWorker + + if (packageName(siblingLspClient) === PACKAGE_NAMES.lspClient && + packageName(siblingPyrightWorker) === PACKAGE_NAMES.pyrightWorker) { + const packagesRoot = dirname(siblingLspClient) + const workspaceRoot = dirname(packagesRoot) + const workspaceManifest = JSON.parse(readFileSync(join(workspaceRoot, 'package.json'), 'utf8')) + if (!workspaceManifest.scripts?.['build:worker:dev']) { + throw new Error(`Local package workspace has no build:worker:dev script: ${workspaceRoot}`) + } + return { lspClient: siblingLspClient, pyrightWorker: siblingPyrightWorker, workspaceRoot } + } + } + + throw new Error( + `Could not find ${PACKAGE_NAMES.lspClient} and ${PACKAGE_NAMES.pyrightWorker} below ${directory}`, + ) +} + +export function npmExecutable(platform = process.platform) { + return platform === 'win32' ? 'npm.cmd' : 'npm' +} + +export function run(command, args, options) { + const result = spawnSync(command, args, { stdio: 'inherit', shell: false, ...options }) + if (result.error) { throw result.error } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`) + } +} + +export function localPackageEnvironment(packages, environment = process.env) { + return { + ...environment, + VIPER_IDE_LOCAL_LSP_CLIENT_PACKAGE: packages.lspClient, + VIPER_IDE_LOCAL_PYRIGHT_WORKER_PACKAGE: packages.pyrightWorker, + } +} + +export function runWithLocalPackages(mode, input, options = {}) { + if (!MODES.has(mode)) { throw new Error(`Unsupported local package mode: ${mode}`) } + const projectRoot = options.projectRoot || dirname(dirname(fileURLToPath(import.meta.url))) + const packages = discoverLocalPackages(input, options.baseDirectory || process.env.INIT_CWD || projectRoot) + const npm = npmExecutable(options.platform) + const execute = options.run || run + const environment = localPackageEnvironment(packages, options.environment) + + console.log(`Building worker from ${packages.workspaceRoot}`) + execute(npm, ['run', 'build:worker:dev'], { cwd: packages.workspaceRoot }) + + console.log(`${mode === 'start' ? 'Starting' : 'Building'} ViperIDE with local packages from ${dirname(packages.lspClient)}`) + execute(npm, ['run', mode === 'start' ? 'start' : 'build'], { + cwd: projectRoot, + env: environment, + }) + + if (mode === 'test') { + console.log('Running ViperIDE browser tests against the local-package build') + execute(npm, ['run', 'test:browser'], { cwd: projectRoot, env: environment }) + } +} + +export function buildFromLocalPackages(input, options = {}) { + return runWithLocalPackages('build', input, options) +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href +if (isMain) { + const mode = process.argv[2] + const input = process.argv[3] + if (!MODES.has(mode) || !input) { + console.error('Usage: node scripts/build-local.mjs ') + process.exitCode = 2 + } else { + try { + runWithLocalPackages(mode, input) + } catch (error) { + console.error(error.message) + process.exitCode = 1 + } + } +} \ No newline at end of file diff --git a/src/ViperIDE.html b/src/ViperIDE.html index bf58dba9..bff18e3b 100644 --- a/src/ViperIDE.html +++ b/src/ViperIDE.html @@ -1,23 +1,33 @@ + ViperIDE - - + + +