├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ ├── dependency-review.yml │ ├── test-real-github-actions-cache-service.yml │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── wireit.js ├── eslint.config.js ├── package-lock.json ├── package.json ├── schema.json ├── src ├── analyzer.ts ├── caching │ ├── cache.ts │ ├── github-actions-cache.ts │ └── local-cache.ts ├── cli-options.ts ├── cli.ts ├── config.ts ├── error.ts ├── event.ts ├── execution │ ├── base.ts │ ├── no-command.ts │ ├── service.ts │ └── standard.ts ├── executor.ts ├── fingerprint.ts ├── ide.ts ├── language-server.ts ├── logging │ ├── combination-logger.ts │ ├── debug-logger.ts │ ├── logger.ts │ ├── metrics-logger.ts │ ├── quiet-logger.ts │ ├── quiet │ │ ├── run-tracker.ts │ │ ├── stack-map.ts │ │ └── writeover-line.ts │ ├── simple-logger.ts │ └── watch-logger.ts ├── script-child-process.ts ├── test │ ├── analysis.test.ts │ ├── basic.test.ts │ ├── cache-common.ts │ ├── cache-github-fake.test.ts │ ├── cache-github-real.test.ts │ ├── cache-local.test.ts │ ├── clean.test.ts │ ├── cli-options.test.ts │ ├── codeactions.test.ts │ ├── copy.test.ts │ ├── delete.test.ts │ ├── diagnostic.test.ts │ ├── errors-analysis.test.ts │ ├── errors-usage.test.ts │ ├── failures.test.ts │ ├── freshness.test.ts │ ├── fs.test.ts │ ├── gc.test.ts │ ├── glob.test.ts │ ├── ide.test.ts │ ├── json-schema.test.ts │ ├── metrics.test.ts │ ├── optimize-mkdirs.test.ts │ ├── parallelism.test.ts │ ├── quiet-logger.test.ts │ ├── schema-test.json │ ├── service.test.ts │ ├── util │ │ ├── check-script-output.ts │ │ ├── cli-options-test-binary.ts │ │ ├── cmd-shim.d.ts │ │ ├── colors.ts │ │ ├── fake-github-actions-cache-server.ts │ │ ├── filesystem-test-rig.ts │ │ ├── graceful-fs.ts │ │ ├── node-version.ts │ │ ├── package-json.ts │ │ ├── rig-test.ts │ │ ├── test-rig-command-child.ts │ │ ├── test-rig-command-interface.ts │ │ ├── test-rig-command.ts │ │ ├── test-rig.ts │ │ └── windows.ts │ └── watch.test.ts ├── util │ ├── ast.ts │ ├── async-cache.ts │ ├── copy.ts │ ├── deferred.ts │ ├── delete.ts │ ├── dispose.ts │ ├── fs.ts │ ├── glob.ts │ ├── line-monitor.ts │ ├── manifest.ts │ ├── optimize-mkdirs.ts │ ├── package-json-reader.ts │ ├── package-json.ts │ ├── script-data-dir.ts │ ├── shuffle.ts │ ├── unreachable.ts │ ├── windows.ts │ └── worker-pool.ts └── watcher.ts ├── tsconfig.json ├── vscode-extension ├── CHANGELOG.md ├── HACKING.md ├── LICENSE ├── README.md ├── built │ ├── logo.png │ └── package.json ├── esbuild.script.mjs ├── package-lock.json ├── package.json ├── src │ ├── client.ts │ ├── scripts │ │ └── copy-to-built.ts │ └── test │ │ ├── fixtures │ │ ├── incorrect │ │ │ └── package.json │ │ └── semantic_errors │ │ │ └── package.json │ │ ├── main.test.ts │ │ └── scripts │ │ ├── runner.ts │ │ └── uvu-entrypoint.ts └── tsconfig.json ├── website ├── .eleventy.cjs ├── content │ ├── 00-index.md │ ├── 01-setup.md │ ├── 02-dependencies.md │ ├── 03-parallelism.md │ ├── 04-files.md │ ├── 05-incremental-build.md │ ├── 06-caching.md │ ├── 07-cleaning.md │ ├── 08-watch.md │ ├── 09-failures-and-errors.md │ ├── 10-package-locks.md │ ├── 11-recipes.md │ ├── 12-reference.md │ ├── 13-requirements.md │ ├── 14-related-tools.md │ ├── _includes │ │ └── layout.njk │ └── _static │ │ ├── images │ │ └── parallel-diagram.svg │ │ ├── style.css │ │ └── wireit.svg └── package.json └── wireit.svg /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | 6 | updates: 7 | - package-ecosystem: 'npm' 8 | directory: '/' 9 | schedule: 10 | interval: 'daily' 11 | ignore: 12 | # Since we support older Node versions, we want our types to reflect the 13 | # lowest supported versions of Node APIs. 14 | - dependency-name: '@types/node' 15 | update-types: ['version-update:semver-major'] 16 | 17 | - package-ecosystem: 'github-actions' 18 | # Workflow files stored in the 19 | # default location of `.github/workflows` 20 | directory: '/' 21 | schedule: 22 | interval: 'daily' 23 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # Code scanning for common bugs provided by GitHub. 2 | # 3 | # See https://github.com/github/codeql/tree/main/javascript/ql/src for the list 4 | # of checks. 5 | name: 'CodeQL' 6 | 7 | on: 8 | push: 9 | branches: ['main'] 10 | pull_request: 11 | # The branches below must be a subset of the branches above 12 | branches: ['main'] 13 | schedule: 14 | - cron: '41 1 * * 1' 15 | workflow_dispatch: 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | permissions: 22 | actions: read 23 | contents: read 24 | security-events: write 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | language: ['typescript'] 30 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 31 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 32 | 33 | steps: 34 | - name: Checkout repository 35 | uses: actions/checkout@v4 36 | 37 | # Initializes the CodeQL tools for scanning. 38 | - name: Initialize CodeQL 39 | uses: github/codeql-action/init@v3 40 | with: 41 | languages: ${{ matrix.language }} 42 | # If you wish to specify custom queries, you can do so here or in a config file. 43 | # By default, queries listed here will override any specified in a config file. 44 | # Prefix the list here with "+" to use these queries and those in the config file. 45 | 46 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 47 | # queries: security-extended,security-and-quality 48 | 49 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 50 | # If this step fails, then you should remove it and run the build manually (see below) 51 | - name: Autobuild 52 | uses: github/codeql-action/autobuild@v3 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 56 | 57 | # If the Autobuild fails above, remove it and uncomment the following three lines. 58 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 59 | 60 | # - run: | 61 | # echo "Run, Build Application using script" 62 | # ./location_of_script_within_repo/buildscript.sh 63 | 64 | - name: Perform CodeQL Analysis 65 | uses: github/codeql-action/analyze@v3 66 | with: 67 | category: '/language:${{matrix.language}}' 68 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request, workflow_dispatch] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v4 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /.github/workflows/test-real-github-actions-cache-service.yml: -------------------------------------------------------------------------------- 1 | name: Test real GitHub Actions cache service 2 | 3 | on: 4 | schedule: 5 | - cron: '14 7 * * *' # 6:07AM PST / 7:07AM PDT 6 | workflow_dispatch: 7 | 8 | jobs: 9 | tests: 10 | strategy: 11 | matrix: 12 | include: 13 | - node: 20 # LTS 14 | os: ubuntu-22.04 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | env: 19 | WIREIT_LOGGER: 'quiet-ci' 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node }} 26 | cache: npm 27 | 28 | # The goal here is to always run the full caching test suite against the 29 | # live GitHub Actions Cache service, even if none of our code has changed, 30 | # in case something changed on the backend. 31 | # 32 | # So, slightly confusingly, although we disable caching of the top-level 33 | # test script below, we do need the runner environment set up to allow 34 | # GitHub caching. 35 | - uses: google/wireit@setup-github-actions-caching/v2 36 | 37 | - run: npm ci 38 | - run: npm run test:cache-github-real 39 | env: 40 | WIREIT_CACHE: none 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | tests: 7 | strategy: 8 | # We support Node Current, LTS, and Maintenance. See 9 | # https://github.com/nodejs/release#release-schedule for release schedule 10 | # 11 | # We test all supported Node versions on Linux, and the oldest and newest 12 | # on macOS/Windows. See 13 | # https://github.com/actions/runner-images?tab=readme-ov-file#available-images 14 | # for the latest available images. 15 | matrix: 16 | include: 17 | # Maintenance 18 | - node: 18 19 | os: ubuntu-22.04 20 | - node: 18 21 | os: macos-13 22 | - node: 18 23 | os: windows-2022 24 | 25 | # LTS 26 | - node: 20 27 | os: ubuntu-22.04 28 | 29 | # Current 30 | - node: 22 31 | os: ubuntu-22.04 32 | - node: 22 33 | os: macos-13 34 | - node: 22 35 | os: windows-2022 36 | 37 | # Allow all matrix configurations to complete, instead of cancelling as 38 | # soon as one fails. Useful because we often have different kinds of 39 | # failures depending on the OS. 40 | fail-fast: false 41 | 42 | # Sometimes windows is far slower than the other OSs. Give it enough 43 | # time to complete if it's going to. 44 | timeout-minutes: 40 45 | runs-on: ${{ matrix.os }} 46 | 47 | env: 48 | WIREIT_LOGGER: 'quiet-ci' 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: actions/setup-node@v4 53 | with: 54 | node-version: ${{ matrix.node }} 55 | cache: npm 56 | 57 | - uses: google/wireit@setup-github-actions-caching/v2 58 | 59 | - run: npm ci 60 | 61 | # See https://code.visualstudio.com/api/working-with-extensions/continuous-integration#github-actions for why we need xvfb-run 62 | - run: npm test 63 | if: runner.os != 'Linux' 64 | # We run tests in parallel on Linux, but not on other OSs. This is 65 | # because the Mac and Windows runners are very flaky, and parallelism 66 | # makes them worse. 67 | env: 68 | WIREIT_PARALLEL: 1 69 | - run: xvfb-run -a npm test 70 | if: runner.os == 'Linux' 71 | - run: npm run test:cache-github-real 72 | 73 | lint-and-format: 74 | timeout-minutes: 5 75 | runs-on: ubuntu-22.04 76 | steps: 77 | - uses: actions/checkout@v4 78 | - uses: actions/setup-node@v4 79 | with: 80 | node-version: 20 81 | cache: npm 82 | 83 | - run: npm ci 84 | - run: npm run lint 85 | - run: npm run format:check 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.eslintcache 2 | /.tsbuildinfo 3 | /.wireit 4 | /*.tgz 5 | /lib 6 | /node_modules 7 | /temp 8 | 9 | /vscode-extension/.wireit 10 | /vscode-extension/lib 11 | /vscode-extension/.tsbuildinfo 12 | /vscode-extension/.vscode-test 13 | /vscode-extension/*.vsix 14 | /vscode-extension/built 15 | 16 | /website/.wireit 17 | /website/_site 18 | /website/node_modules 19 | 20 | .DS_Store 21 | 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /lib 2 | /node_modules 3 | /package-lock.json 4 | /temp 5 | /.wireit 6 | /vscode-extension/.vscode-test 7 | /vscode-extension/.wireit 8 | /vscode-extension/lib 9 | /vscode-extension/built 10 | /website/.wireit 11 | /website/_site 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.preferences.importModuleSpecifierEnding": "js", 3 | "typescript.preferences.importModuleSpecifierEnding": "js", 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "typescript.tsdk": "node_modules/typescript/lib", 6 | "typescript.enablePromptUseWorkspaceTsdk": true 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement (CLA). You (or your employer) retain the copyright to your 10 | contribution; this simply gives us permission to use and redistribute your 11 | contributions as part of the project. Head over to 12 | to see your current agreements on file or 13 | to sign a new one. 14 | 15 | You generally only need to submit a CLA once, so if you've already submitted one 16 | (even if it was for a different project), you probably don't need to do it 17 | again. 18 | 19 | ## Code Reviews 20 | 21 | All submissions, including submissions by project members, require review. We 22 | use GitHub pull requests for this purpose. Consult 23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 24 | information on using pull requests. 25 | 26 | ## Community Guidelines 27 | 28 | This project follows 29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/). 30 | 31 | ## Getting started 32 | 33 | ```sh 34 | git clone https://github.com/google/wireit.git 35 | cd wireit 36 | npm ci 37 | npm run build 38 | ``` 39 | 40 | ## Running tests 41 | 42 | ```sh 43 | npm test 44 | npm test watch 45 | ``` 46 | 47 | ### Testing environment variables 48 | 49 | - `TEST_TIMEOUT`: Default millisecond timeout for test cases. 50 | - `SHOW_TEST_OUTPUT`: Set to show all `stdout` and `stderr` from spawned wireit 51 | invocations in test cases. 52 | 53 | ## Self-hosting version 54 | 55 | Wireit is self-hosting: it is built and tested with itself. However, we don't 56 | want to build and test with the exact same code we are editing during 57 | development, because if we break something, we might be unable to build or test 58 | at all, or we might build or test incorrectly (e.g. we might think tests passed 59 | when actually the tests didn't even run). 60 | 61 | For this reason, we depend on the latest published version in our 62 | `devDependencies`, instead of running directly against source. To update this 63 | version, run: 64 | 65 | ```sh 66 | npm upgrade wireit 67 | ``` 68 | -------------------------------------------------------------------------------- /bin/wireit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * @license 5 | * Copyright 2022 Google LLC 6 | * SPDX-License-Identifier: Apache-2.0 7 | */ 8 | 9 | import '../lib/cli.js'; 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2024 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import eslint from '@eslint/js'; 8 | import noOnlyTests from 'eslint-plugin-no-only-tests'; 9 | import tseslint from 'typescript-eslint'; 10 | 11 | /** 12 | * We want to be able to lint non-TypeScript files. If we don't guard our 13 | * TypeScript rules with a "files" constraint, eslint will try to lint all files 14 | * using the TypeScript parser, which will fail for projects outside a 15 | * TypeScript project. Maybe there is a simpler way to do this? 16 | */ 17 | const onlyTypeScriptFiles = (configs) => 18 | configs.map((config) => ({files: ['**/*.ts'], ...config})); 19 | 20 | export default [ 21 | { 22 | // List all visible files: 23 | // npx eslint --debug 2>&1 | grep "eslint:eslint Lint" | cut -f 4- -d" " | sort 24 | ignores: [ 25 | '**/.wireit/', 26 | '**/node_modules/', 27 | 'lib/', 28 | 'vscode-extension/.vscode-test/', 29 | 'vscode-extension/lib/', 30 | 'vscode-extension/built/', 31 | ], 32 | }, 33 | eslint.configs.recommended, 34 | ...onlyTypeScriptFiles([ 35 | ...tseslint.configs.strictTypeChecked, 36 | { 37 | languageOptions: { 38 | parserOptions: { 39 | projectService: true, 40 | tsconfigRootDir: import.meta.dirname, 41 | }, 42 | }, 43 | plugins: { 44 | 'no-only-tests': noOnlyTests, 45 | }, 46 | rules: { 47 | 'no-only-tests/no-only-tests': 'error', 48 | '@typescript-eslint/no-unused-vars': [ 49 | 'error', 50 | {argsIgnorePattern: '^_', varsIgnorePattern: '^_'}, 51 | ], 52 | '@typescript-eslint/no-non-null-assertion': 'off', 53 | '@typescript-eslint/no-useless-constructor': 'off', 54 | '@typescript-eslint/only-throw-error': 'off', 55 | '@typescript-eslint/no-confusing-void-expression': 'off', 56 | '@typescript-eslint/restrict-template-expressions': 'off', 57 | '@typescript-eslint/no-unnecessary-condition': 'off', 58 | '@typescript-eslint/no-unnecessary-type-arguments': 'off', 59 | '@typescript-eslint/no-unnecessary-template-expression': 'off', 60 | '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', 61 | }, 62 | }, 63 | ]), 64 | ]; 65 | -------------------------------------------------------------------------------- /src/caching/cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {ScriptReference} from '../config.js'; 8 | import type {Fingerprint} from '../fingerprint.js'; 9 | import type {AbsoluteEntry} from '../util/glob.js'; 10 | 11 | /** 12 | * Saves and restores output files to some cache store (e.g. local disk or 13 | * remote server). 14 | */ 15 | export interface Cache { 16 | /** 17 | * Check for a cache hit for the given script and fingerprint. Don't write it to 18 | * disk yet, instead return a {@link CacheHit} which can be used to control 19 | * when writing occurs. 20 | * 21 | * @param script The script whose output will be read from the cache. 22 | * @param fingerprint The string-encoded fingerprint for the script. 23 | * @return Promise of a {@link CacheHit} if there was an entry in the cache, 24 | * or undefined if there was not. 25 | */ 26 | get( 27 | script: ScriptReference, 28 | fingerprint: Fingerprint, 29 | ): Promise; 30 | 31 | /** 32 | * Write the given file paths to the cache if possible, keyed by the given 33 | * script and fingerprint. 34 | * 35 | * It is valid for an implementation to decide not to write to the cache and 36 | * return false, for example if the contents are too large. 37 | * 38 | * @param script The script whose output will be saved to the cache. 39 | * @param fingerprint The string-encoded fingerprint for the script. 40 | * @param absoluteFiles The absolute output files to cache. 41 | * @returns Whether the cache was written. 42 | */ 43 | set( 44 | script: ScriptReference, 45 | fingerprint: Fingerprint, 46 | absoluteFiles: AbsoluteEntry[], 47 | ): Promise; 48 | } 49 | 50 | /** 51 | * The result of {@link Cache.get}. 52 | * 53 | * Note the reason {@link Cache.get} reteurns this class instead of immediately 54 | * applying writing the cached output is so that the {@link Executor} can 55 | * control the timing of when cached output is written. 56 | */ 57 | export interface CacheHit { 58 | /** 59 | * Write the cached files to disk. 60 | * 61 | * It is assumed that any existing stale output has already been cleaned 62 | * before this method is called. 63 | */ 64 | apply(): Promise; 65 | } 66 | -------------------------------------------------------------------------------- /src/caching/local-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from '../util/fs.js'; 8 | import * as pathlib from 'path'; 9 | import {createHash} from 'crypto'; 10 | import {getScriptDataDir} from '../util/script-data-dir.js'; 11 | import {copyEntries} from '../util/copy.js'; 12 | import {glob} from '../util/glob.js'; 13 | 14 | import type {Cache, CacheHit} from './cache.js'; 15 | import type {ScriptReference} from '../config.js'; 16 | import type {Fingerprint} from '../fingerprint.js'; 17 | import type {AbsoluteEntry} from '../util/glob.js'; 18 | 19 | /** 20 | * Caches script output to each package's 21 | * ".wireit//cache/" folder. 22 | */ 23 | export class LocalCache implements Cache { 24 | async get( 25 | script: ScriptReference, 26 | fingerprint: Fingerprint, 27 | ): Promise { 28 | const cacheDir = this.#getCacheDir(script, fingerprint); 29 | try { 30 | await fs.access(cacheDir); 31 | } catch (error) { 32 | if ((error as Error & {code?: string}).code === 'ENOENT') { 33 | return; 34 | } 35 | throw error; 36 | } 37 | return new LocalCacheHit(cacheDir, script.packageDir); 38 | } 39 | 40 | async set( 41 | script: ScriptReference, 42 | fingerprint: Fingerprint, 43 | absoluteFiles: AbsoluteEntry[], 44 | ): Promise { 45 | // TODO(aomarks) A script's cache directory currently just grows forever. 46 | // We'll have the "clean" command to help with manual cleanup, but we'll 47 | // almost certainly want an automated way to limit the size of the cache 48 | // directory (e.g. LRU capped to some number of entries). 49 | // https://github.com/google/wireit/issues/71 50 | const absCacheDir = this.#getCacheDir(script, fingerprint); 51 | // Note fs.mkdir returns the first created directory, or undefined if no 52 | // directory was created. 53 | const existed = 54 | (await fs.mkdir(absCacheDir, {recursive: true})) === undefined; 55 | if (existed) { 56 | // This is an unexpected error because the Executor should already have 57 | // checked for an existing cache hit. 58 | throw new Error(`Did not expect ${absCacheDir} to already exist.`); 59 | } 60 | await copyEntries(absoluteFiles, script.packageDir, absCacheDir); 61 | return true; 62 | } 63 | 64 | #getCacheDir(script: ScriptReference, fingerprint: Fingerprint): string { 65 | return pathlib.join( 66 | getScriptDataDir(script), 67 | 'cache', 68 | createHash('sha256').update(fingerprint.string).digest('hex'), 69 | ); 70 | } 71 | } 72 | 73 | class LocalCacheHit implements CacheHit { 74 | /** 75 | * The folder where the cached output is stored. Assumed to exist. 76 | */ 77 | readonly #source: string; 78 | 79 | /** 80 | * The folder where the cached output should be written when {@link apply} is 81 | * called. 82 | */ 83 | readonly #destination: string; 84 | 85 | constructor(source: string, destination: string) { 86 | this.#source = source; 87 | this.#destination = destination; 88 | } 89 | 90 | async apply(): Promise { 91 | const entries = await glob(['**'], { 92 | cwd: this.#source, 93 | followSymlinks: false, 94 | includeDirectories: true, 95 | expandDirectories: true, 96 | // Shouldn't ever happen, but would be really weird. 97 | throwIfOutsideCwd: true, 98 | }); 99 | await copyEntries(entries, this.#source, this.#destination); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Analyzer} from './analyzer.js'; 8 | import {getOptions, Options, packageDir} from './cli-options.js'; 9 | import {Result} from './error.js'; 10 | import {Failure} from './event.js'; 11 | import {Executor} from './executor.js'; 12 | import {SimpleLogger} from './logging/simple-logger.js'; 13 | import {Console} from './logging/logger.js'; 14 | import {unreachable} from './util/unreachable.js'; 15 | import {WorkerPool} from './util/worker-pool.js'; 16 | 17 | const run = async (options: Options): Promise> => { 18 | using logger = options.logger; 19 | const workerPool = new WorkerPool(options.numWorkers); 20 | 21 | let cache; 22 | switch (options.cache) { 23 | case 'local': { 24 | // Import dynamically so that we import fewer unnecessary modules. 25 | const {LocalCache} = await import('./caching/local-cache.js'); 26 | cache = new LocalCache(); 27 | break; 28 | } 29 | case 'github': { 30 | const {GitHubActionsCache} = await import( 31 | './caching/github-actions-cache.js' 32 | ); 33 | const cacheResult = await GitHubActionsCache.create(logger); 34 | if (cacheResult.ok) { 35 | cache = cacheResult.value; 36 | } else { 37 | cache = undefined; 38 | console.warn( 39 | '⚠️ Error initializing GitHub cache. Caching is disabled for this run', 40 | ); 41 | logger.log({ 42 | script: options.script, 43 | ...cacheResult.error, 44 | }); 45 | } 46 | break; 47 | } 48 | case 'none': { 49 | cache = undefined; 50 | break; 51 | } 52 | default: { 53 | throw new Error( 54 | `Unhandled cache: ${unreachable(options.cache) as string}`, 55 | ); 56 | } 57 | } 58 | 59 | if (options.watch) { 60 | const {Watcher} = await import('./watcher.js'); 61 | const watcher = new Watcher( 62 | options.script, 63 | options.extraArgs, 64 | logger, 65 | workerPool, 66 | cache, 67 | options.failureMode, 68 | options.agent, 69 | options.watch, 70 | ); 71 | process.on('SIGINT', () => { 72 | watcher.abort(); 73 | }); 74 | process.on('SIGTERM', () => { 75 | watcher.abort(); 76 | }); 77 | await watcher.watch(); 78 | return {ok: true, value: undefined}; 79 | } else { 80 | const analyzer = new Analyzer(options.agent, logger); 81 | const {config} = await analyzer.analyze(options.script, options.extraArgs); 82 | if (!config.ok) { 83 | return config; 84 | } 85 | const executor = new Executor( 86 | config.value, 87 | logger, 88 | workerPool, 89 | cache, 90 | options.failureMode, 91 | undefined, 92 | false, 93 | ); 94 | process.on('SIGINT', () => { 95 | executor.abort(); 96 | }); 97 | process.on('SIGTERM', () => { 98 | executor.abort(); 99 | }); 100 | const {persistentServices, errors} = await executor.execute(); 101 | if (persistentServices.size > 0) { 102 | for (const service of persistentServices.values()) { 103 | const result = await service.terminated; 104 | if (!result.ok) { 105 | errors.push(result.error); 106 | } 107 | } 108 | } 109 | logger.printMetrics(); 110 | return errors.length === 0 111 | ? {ok: true, value: undefined} 112 | : {ok: false, error: errors}; 113 | } 114 | }; 115 | 116 | const optionsResult = await getOptions(); 117 | if (!optionsResult.ok) { 118 | // if we can't figure out our options, we can't figure out what logger 119 | // we should use here, so just use the default logger. 120 | const console = new Console(process.stderr, process.stderr); 121 | const logger = new SimpleLogger(packageDir ?? process.cwd(), console); 122 | logger.log(optionsResult.error); 123 | process.exit(1); 124 | } 125 | 126 | const options = optionsResult.value; 127 | const result = await run(options); 128 | if (!result.ok) { 129 | for (const failure of result.error) { 130 | options.logger.log(failure); 131 | } 132 | process.exitCode = 1; 133 | } 134 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type { 8 | JsonFile, 9 | ArrayNode, 10 | JsonAstNode, 11 | NamedAstNode, 12 | } from './util/ast.js'; 13 | import type {Failure} from './event.js'; 14 | import type {PotentiallyValidScriptConfig} from './analyzer.js'; 15 | 16 | /** 17 | * The location on disk of an npm package. 18 | */ 19 | export interface PackageReference { 20 | /** Absolute path to an npm package directory. */ 21 | packageDir: string; 22 | } 23 | 24 | /** 25 | * The name and package location of a script. 26 | */ 27 | export interface ScriptReference extends PackageReference { 28 | /** A concrete script name (no ./ or $WORKSPACES etc.) */ 29 | name: string; 30 | } 31 | 32 | /** 33 | * A script with a defined command. 34 | */ 35 | export interface ScriptReferenceWithCommand extends ScriptReference { 36 | /** 37 | * The shell command to execute. 38 | */ 39 | command: JsonAstNode; 40 | 41 | /** 42 | * Extra arguments to pass to the command. 43 | */ 44 | extraArgs: string[] | undefined; 45 | 46 | /** 47 | * Environment variables. 48 | */ 49 | env: Record; 50 | } 51 | 52 | export interface Dependency< 53 | Config extends PotentiallyValidScriptConfig = ScriptConfig, 54 | > { 55 | config: Config; 56 | specifier: JsonAstNode; 57 | cascade: boolean; 58 | } 59 | 60 | export type ScriptConfig = 61 | | NoCommandScriptConfig 62 | | StandardScriptConfig 63 | | ServiceScriptConfig; 64 | 65 | /** 66 | * A script that doesn't run or produce anything. A pass-through for 67 | * dependencies and/or files. 68 | */ 69 | export interface NoCommandScriptConfig extends BaseScriptConfig { 70 | command: undefined; 71 | extraArgs: undefined; 72 | service: undefined; 73 | env: Record; 74 | } 75 | 76 | /** 77 | * A script with a command that exits by itself. 78 | */ 79 | export interface StandardScriptConfig 80 | extends BaseScriptConfig, 81 | ScriptReferenceWithCommand { 82 | service: undefined; 83 | } 84 | 85 | export type ServiceConfig = { 86 | readyWhen: { 87 | lineMatches: RegExp | undefined; 88 | }; 89 | }; 90 | 91 | /** 92 | * A service script. 93 | */ 94 | export interface ServiceScriptConfig 95 | extends BaseScriptConfig, 96 | ScriptReferenceWithCommand { 97 | service: ServiceConfig; 98 | 99 | /** 100 | * Whether this service persists beyond the initial execution phase. 101 | * 102 | * When true, this service will keep running until the user exits wireit, or 103 | * until its fingerprint changes in watch mode, requiring a restart. 104 | * 105 | * When false, this service will start only if it is needed by a standard 106 | * script, and will stop when that dependent is done. We call these scripts 107 | * "ephemeral". 108 | * 109 | * So, this is true when there is a path from the entrypoint script to the 110 | * service, which does not pass through a standard script. 111 | * 112 | * Example: 113 | * 114 | * start 115 | * (no-command) 116 | * / \ 117 | * ▼ ▼ 118 | * serve:api serve:static 119 | * (persistent service) (persistent service) 120 | * | | 121 | * ▼ ▼ 122 | * serve:db build:assets 123 | * (persistent service) (standard) 124 | * | 125 | * ▼ 126 | * serve:playwright 127 | * (ephemeral service) 128 | */ 129 | isPersistent: boolean; 130 | 131 | /** 132 | * Scripts that depend on this service. 133 | */ 134 | serviceConsumers: Array; 135 | } 136 | 137 | /** 138 | * The name and location of a script, along with its full configuration. 139 | */ 140 | interface BaseScriptConfig extends ScriptReference { 141 | state: 'valid'; 142 | 143 | /** 144 | * Scripts that must run before this one. 145 | * 146 | * Note that the {@link Analyzer} returns dependencies sorted by package 147 | * directory + script name, but the {@link Executor} then randomizes the order 148 | * during execution. 149 | */ 150 | dependencies: Array; 151 | 152 | /** 153 | * The services that need to be started before we can run. 154 | */ 155 | services: Array; 156 | 157 | /** 158 | * Input file globs for this script. 159 | * 160 | * If undefined, the input files are unknown (meaning the script cannot safely 161 | * be cached). If defined but empty, there are no input files (meaning the 162 | * script can safely be cached). 163 | */ 164 | files: ArrayNode | undefined; 165 | 166 | /** 167 | * Output file globs for this script. 168 | */ 169 | output: ArrayNode | undefined; 170 | 171 | /** 172 | * When to clean output: 173 | * 174 | * - true: Before the script executes, and before restoring from cache. 175 | * - false: Before restoring from cache. 176 | * - "if-file-deleted": If an input file has been deleted, and before restoring from 177 | * cache. 178 | */ 179 | clean: boolean | 'if-file-deleted'; 180 | 181 | /** 182 | * Whether the script should run in service mode. 183 | */ 184 | service: ServiceConfig | undefined; 185 | 186 | /** 187 | * The command string in the scripts section. i.e.: 188 | * 189 | * ```json 190 | * "scripts": { 191 | * "build": "tsc" 192 | * ~~~~~ 193 | * } 194 | * ``` 195 | */ 196 | scriptAstNode: NamedAstNode | undefined; 197 | 198 | /** 199 | * The entire config in the wireit section. i.e.: 200 | * 201 | * ```json 202 | * "build": { 203 | * ~ 204 | * "command": "tsc" 205 | * ~~~~~~~~~~~~~~~~~~ 206 | * } 207 | * ~ 208 | * ``` 209 | */ 210 | configAstNode: NamedAstNode | undefined; 211 | 212 | /** The parsed JSON file that declared this script. */ 213 | declaringFile: JsonFile; 214 | failures: Failure[]; 215 | } 216 | 217 | /** 218 | * Convert a {@link ScriptReference} to a string that can be used as a key in a 219 | * Set, Map, etc. 220 | */ 221 | export const scriptReferenceToString = ({ 222 | packageDir, 223 | name, 224 | }: ScriptReference): ScriptReferenceString => 225 | JSON.stringify([packageDir, name]) as ScriptReferenceString; 226 | 227 | /** 228 | * Inverse of {@link scriptReferenceToString}. 229 | */ 230 | export const stringToScriptReference = ( 231 | str: ScriptReferenceString, 232 | ): ScriptReference => { 233 | const [packageDir, name] = JSON.parse(str) as [string, string]; 234 | return {packageDir, name}; 235 | }; 236 | 237 | /** 238 | * Brand that ensures {@link stringToScriptReference} only takes strings that 239 | * were returned by {@link scriptReferenceToString}. 240 | */ 241 | export type ScriptReferenceString = string & { 242 | __ScriptReferenceStringBrand__: never; 243 | }; 244 | -------------------------------------------------------------------------------- /src/execution/base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {shuffle} from '../util/shuffle.js'; 8 | import {Fingerprint} from '../fingerprint.js'; 9 | import {Deferred} from '../util/deferred.js'; 10 | 11 | import {convertExceptionToFailure, type Result} from '../error.js'; 12 | import type {Executor} from '../executor.js'; 13 | import type {Dependency, ScriptConfig} from '../config.js'; 14 | import type {Logger} from '../logging/logger.js'; 15 | import type {Failure} from '../event.js'; 16 | 17 | export type ExecutionResult = Result; 18 | 19 | /** 20 | * What to do when a script failure occurs: 21 | * 22 | * - `no-new`: Allow running scripts to finish, but don't start new ones. 23 | * - `continue`: Allow running scripts to finish, and start new ones unless a 24 | * dependency failed. 25 | * - `kill`: Immediately kill running scripts, and don't start new ones. 26 | */ 27 | export type FailureMode = 'no-new' | 'continue' | 'kill'; 28 | 29 | let executionConstructorHook: 30 | | ((executor: BaseExecution) => void) 31 | | undefined; 32 | 33 | /** 34 | * For GC testing only. A function that is called whenever an Execution is 35 | * constructed. 36 | */ 37 | export function registerExecutionConstructorHook( 38 | fn: typeof executionConstructorHook, 39 | ) { 40 | executionConstructorHook = fn; 41 | } 42 | 43 | /** 44 | * A single execution of a specific script. 45 | */ 46 | export abstract class BaseExecution { 47 | protected readonly _config: T; 48 | protected readonly _executor: Executor; 49 | protected readonly _logger: Logger; 50 | #fingerprint?: Promise; 51 | 52 | constructor(config: T, executor: Executor, logger: Logger) { 53 | executionConstructorHook?.(this); 54 | this._config = config; 55 | this._executor = executor; 56 | this._logger = logger; 57 | } 58 | 59 | /** 60 | * Execute this script and return its fingerprint. Cached, so safe to call 61 | * multiple times. 62 | */ 63 | async execute(): Promise { 64 | try { 65 | return await (this.#fingerprint ??= this._execute()); 66 | } catch (error) { 67 | return convertExceptionToFailure(error, this._config); 68 | } 69 | } 70 | 71 | protected abstract _execute(): Promise; 72 | 73 | /** 74 | * Execute all of this script's dependencies. 75 | */ 76 | protected async _executeDependencies(): Promise< 77 | Result, Failure[]> 78 | > { 79 | // Randomize the order we execute dependencies to make it less likely for a 80 | // user to inadvertently depend on any specific order, which could indicate 81 | // a missing edge in the dependency graph. 82 | shuffle(this._config.dependencies); 83 | 84 | const dependencyResults = await Promise.all( 85 | this._config.dependencies.map((dependency) => { 86 | return this._executor.getExecution(dependency.config).execute(); 87 | }), 88 | ); 89 | const results: Array<[Dependency, Fingerprint]> = []; 90 | const errors = new Set(); 91 | for (let i = 0; i < dependencyResults.length; i++) { 92 | const result = dependencyResults[i]!; 93 | if (!result.ok) { 94 | for (const error of result.error) { 95 | errors.add(error); 96 | } 97 | } else { 98 | results.push([this._config.dependencies[i]!, result.value]); 99 | } 100 | } 101 | if (errors.size > 0) { 102 | return {ok: false, error: [...errors]}; 103 | } 104 | return {ok: true, value: results}; 105 | } 106 | } 107 | 108 | /** 109 | * A single execution of a specific script which has a command. 110 | */ 111 | export abstract class BaseExecutionWithCommand< 112 | T extends ScriptConfig & { 113 | command: Exclude; 114 | }, 115 | > extends BaseExecution { 116 | protected readonly _servicesNotNeeded = new Deferred(); 117 | 118 | /** 119 | * Resolves when this script no longer needs any of its service dependencies 120 | * to be running. This could happen because it finished, failed, or never 121 | * needed to run at all. 122 | */ 123 | readonly servicesNotNeeded = this._servicesNotNeeded.promise; 124 | 125 | /** 126 | * Resolves when any of the services this script depends on have terminated 127 | * (see {@link ServiceScriptExecution.terminated} for exact definiton). 128 | */ 129 | protected readonly _anyServiceTerminated = Promise.race( 130 | this._config.services.map( 131 | (service) => this._executor.getExecution(service).terminated, 132 | ), 133 | ); 134 | 135 | /** 136 | * Ensure that all of the services this script depends on are running. 137 | */ 138 | protected async _startServices(): Promise> { 139 | if (this._config.services.length > 0) { 140 | const results = await Promise.all( 141 | this._config.services.map((service) => 142 | this._executor.getExecution(service).start(), 143 | ), 144 | ); 145 | const errors: Failure[] = []; 146 | for (const result of results) { 147 | if (!result.ok) { 148 | errors.push(result.error); 149 | } 150 | } 151 | if (errors.length > 0) { 152 | return {ok: false, error: errors}; 153 | } 154 | } 155 | return {ok: true, value: undefined}; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/execution/no-command.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {BaseExecution} from './base.js'; 8 | import {Fingerprint} from '../fingerprint.js'; 9 | 10 | import type {ExecutionResult} from './base.js'; 11 | import type {NoCommandScriptConfig} from '../config.js'; 12 | 13 | /** 14 | * Execution for a {@link NoCommandScriptConfig}. 15 | */ 16 | export class NoCommandScriptExecution extends BaseExecution { 17 | protected override async _execute(): Promise { 18 | const dependencyFingerprints = await this._executeDependencies(); 19 | if (!dependencyFingerprints.ok) { 20 | return dependencyFingerprints; 21 | } 22 | const fingerprint = await Fingerprint.compute( 23 | this._config, 24 | dependencyFingerprints.value, 25 | ); 26 | if (!fingerprint.ok) { 27 | return { 28 | ok: false, 29 | error: [fingerprint.error], 30 | }; 31 | } 32 | this._logger.log({ 33 | script: this._config, 34 | type: 'success', 35 | reason: 'no-command', 36 | }); 37 | return {ok: true, value: fingerprint.value}; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/language-server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // This is where the bulk of the work of the extension happens. This file 8 | // runs in its own process, and communicates with the main process via 9 | // node IPC. 10 | 11 | // jsonc-parser often uses 'any' when they mean 'unknown'. We might want to 12 | // declare our own types for them, but for now, we'll just quiet down eslint. 13 | 14 | import { 15 | createConnection, 16 | TextDocuments, 17 | ProposedFeatures, 18 | InitializeResult, 19 | TextDocumentSyncKind, 20 | CodeActionKind, 21 | } from 'vscode-languageserver/node'; 22 | import * as url from 'url'; 23 | 24 | import {TextDocument} from 'vscode-languageserver-textdocument'; 25 | import {inspect} from 'util'; 26 | import {IdeAnalyzer} from './ide.js'; 27 | 28 | const ideAnalyzer = new IdeAnalyzer(); 29 | const connection = createConnection(ProposedFeatures.all); 30 | 31 | connection.onInitialize((init) => { 32 | const workspacePaths: string[] = []; 33 | for (const folder of init.workspaceFolders ?? []) { 34 | workspacePaths.push(url.fileURLToPath(folder.uri)); 35 | } 36 | ideAnalyzer.setWorkspaceRoots(workspacePaths); 37 | const result: InitializeResult = { 38 | capabilities: { 39 | textDocumentSync: TextDocumentSyncKind.Incremental, 40 | // If we add any new features, we'll generally need to declare them 41 | // here. 42 | codeActionProvider: { 43 | codeActionKinds: [ 44 | CodeActionKind.QuickFix, 45 | CodeActionKind.RefactorExtract, 46 | ], 47 | }, 48 | definitionProvider: true, 49 | referencesProvider: true, 50 | completionProvider: { 51 | // We don't have more information later, so don't bother asking. 52 | resolveProvider: false, 53 | completionItem: {}, 54 | triggerCharacters: ['"', ':', '/'], 55 | }, 56 | }, 57 | }; 58 | return result; 59 | }); 60 | 61 | function log(...values: unknown[]) { 62 | for (const value of values) { 63 | let message: string; 64 | if (typeof value === 'string') { 65 | message = value; 66 | } else { 67 | message = inspect(value, {depth: 4}); 68 | } 69 | connection.console.log(message); 70 | } 71 | } 72 | 73 | // So that we can just console.log and console.error as usual. 74 | console.log = log; 75 | console.error = log; 76 | 77 | const documents: TextDocuments = new TextDocuments(TextDocument); 78 | 79 | let requestIdCounter = 0; 80 | const getAndSendDiagnostics = async () => { 81 | requestIdCounter++; 82 | const requestId = requestIdCounter; 83 | const diagnosticsByFile = await ideAnalyzer.getDiagnostics(); 84 | if (requestId !== requestIdCounter) { 85 | return; // another request has been made since this one 86 | } 87 | for (const path of ideAnalyzer.openFiles) { 88 | const diagnostics = diagnosticsByFile.get(path) ?? []; 89 | void connection.sendDiagnostics({ 90 | uri: url.pathToFileURL(path).toString(), 91 | diagnostics: [...diagnostics], 92 | }); 93 | } 94 | }; 95 | 96 | const updateOpenFile = (document: TextDocument) => { 97 | if (document.languageId !== 'json') { 98 | return; 99 | } 100 | const path = url.fileURLToPath(document.uri); 101 | if (!path.endsWith('package.json')) { 102 | return; 103 | } 104 | const contents = document.getText(); 105 | ideAnalyzer.setOpenFileContents(path, contents); 106 | void getAndSendDiagnostics(); 107 | }; 108 | 109 | documents.onDidOpen((event) => { 110 | updateOpenFile(event.document); 111 | }); 112 | 113 | documents.onDidChangeContent((change) => { 114 | updateOpenFile(change.document); 115 | }); 116 | 117 | documents.onDidClose((change) => { 118 | const path = url.fileURLToPath(change.document.uri); 119 | ideAnalyzer.closeFile(path); 120 | void getAndSendDiagnostics(); 121 | // Clear diagnostics for closed file. 122 | void connection.sendDiagnostics({ 123 | uri: change.document.uri, 124 | diagnostics: [], 125 | }); 126 | }); 127 | 128 | connection.onCodeAction(async (params) => { 129 | const document = documents.get(params.textDocument.uri); 130 | if (document === undefined) { 131 | return []; 132 | } 133 | const path = url.fileURLToPath(document.uri); 134 | const actions = await ideAnalyzer.getCodeActions(path, params.range); 135 | return actions; 136 | }); 137 | 138 | connection.onDefinition(async (params) => { 139 | const path = url.fileURLToPath(params.textDocument.uri); 140 | const position = params.position; 141 | return ideAnalyzer.getDefinition(path, position); 142 | }); 143 | 144 | connection.onReferences(async (params) => { 145 | // TODO: handle params.context.includeDeclaration 146 | const path = url.fileURLToPath(params.textDocument.uri); 147 | const position = params.position; 148 | return ideAnalyzer.findAllReferences(path, position); 149 | }); 150 | 151 | connection.onCompletion(async (params) => { 152 | const path = url.fileURLToPath(params.textDocument.uri); 153 | const position = params.position; 154 | return ideAnalyzer.getCompletions(path, position); 155 | }); 156 | 157 | // Actually start listening 158 | documents.listen(connection); 159 | connection.listen(); 160 | -------------------------------------------------------------------------------- /src/logging/combination-logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Event} from '../event.js'; 8 | import {Console, Logger} from './logger.js'; 9 | 10 | // To prevent using the global console accidentally, we shadow it with 11 | // undefined 12 | const console = undefined; 13 | function markAsUsed(_: unknown) {} 14 | markAsUsed(console); 15 | 16 | /** 17 | * A {@link Logger} that logs to multiple loggers. 18 | */ 19 | export class CombinationLogger implements Logger { 20 | readonly console: Console; 21 | readonly #loggers: readonly Logger[]; 22 | 23 | constructor(loggers: readonly Logger[], console: Console) { 24 | this.console = console; 25 | this.#loggers = loggers; 26 | } 27 | 28 | log(event: Event): void { 29 | for (const logger of this.#loggers) { 30 | logger.log(event); 31 | } 32 | } 33 | printMetrics(): void { 34 | for (const logger of this.#loggers) { 35 | logger.printMetrics?.(); 36 | } 37 | } 38 | getWatchLogger?(): Logger { 39 | const watchLoggers = this.#loggers.map( 40 | (logger) => logger.getWatchLogger?.() ?? logger, 41 | ); 42 | return new CombinationLogger(watchLoggers, this.console); 43 | } 44 | 45 | [Symbol.dispose](): void { 46 | for (const logger of this.#loggers) { 47 | logger[Symbol.dispose]?.(); 48 | } 49 | this.console[Symbol.dispose](); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/logging/debug-logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {SimpleLogger} from './simple-logger.js'; 8 | import {Event} from '../event.js'; 9 | import {inspect} from 'node:util'; 10 | 11 | // To prevent using the global console accidentally, we shadow it with 12 | // undefined 13 | const console = undefined; 14 | function markAsUsed(_: unknown) {} 15 | markAsUsed(console); 16 | 17 | /** 18 | * A {@link Logger} for logging debug information, mainly in tests. 19 | */ 20 | export class DebugLogger extends SimpleLogger { 21 | override log(event: Event) { 22 | switch (event.type) { 23 | case 'info': 24 | this.console.log(` ${event.detail}`); 25 | break; 26 | case 'failure': 27 | this.console.log(` ${event.reason}`); 28 | break; 29 | case 'output': 30 | // too verbose, log nothing 31 | return; 32 | case 'success': 33 | this.console.log(` ${event.reason}`); 34 | break; 35 | default: { 36 | const never: never = event; 37 | throw new Error(`Unknown event type: ${inspect(never)}`); 38 | } 39 | } 40 | super.log(event); 41 | } 42 | 43 | override [Symbol.dispose](): void { 44 | super[Symbol.dispose](); 45 | this.console[Symbol.dispose](); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/logging/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {Event} from '../event.js'; 8 | import '../util/dispose.js'; 9 | import {Console as NodeConsole} from 'node:console'; 10 | 11 | // To prevent using the global console accidentally, we shadow it with 12 | // undefined 13 | const console = undefined; 14 | function markAsUsed(_: unknown) {} 15 | markAsUsed(console); 16 | 17 | export class Console extends NodeConsole { 18 | readonly stdout: NodeJS.WritableStream; 19 | readonly stderr: NodeJS.WritableStream; 20 | readonly #closeStreams; 21 | #closed = false; 22 | constructor( 23 | stdout: NodeJS.WritableStream, 24 | stderr: NodeJS.WritableStream, 25 | closeStreams = false, 26 | ) { 27 | super(stdout, stderr); 28 | this.stdout = stdout; 29 | this.stderr = stderr; 30 | this.#closeStreams = closeStreams; 31 | } 32 | 33 | [Symbol.dispose](): void { 34 | if (this.#closed) { 35 | return; 36 | } 37 | this.#closed = true; 38 | if (this.#closeStreams) { 39 | this.stdout.end(); 40 | this.stderr.end(); 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * Logs Wireit events in some way. 47 | */ 48 | export interface Logger extends Disposable { 49 | readonly console: Console; 50 | 51 | log(event: Event): void; 52 | printMetrics(): void; 53 | 54 | // Some loggers need additional logic when run in watch mode. 55 | // If this method is present, we'll call it and use the result when in 56 | // watch mode. 57 | getWatchLogger?(): Logger; 58 | } 59 | 60 | /** 61 | * When true, we're debugging the logger itself, so a logger should log with 62 | * more verbosity, and not overwrite previously written lines. 63 | */ 64 | export const DEBUG = Boolean(process.env['WIREIT_DEBUG_LOGGER']); 65 | -------------------------------------------------------------------------------- /src/logging/metrics-logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {hrtime} from 'process'; 8 | import {Event} from '../event.js'; 9 | import {SimpleLogger} from './simple-logger.js'; 10 | import {Console} from './logger.js'; 11 | 12 | // To prevent using the global console accidentally, we shadow it with 13 | // undefined 14 | const console = undefined; 15 | function markAsUsed(_: unknown) {} 16 | markAsUsed(console); 17 | 18 | interface Metric { 19 | name: string; 20 | matches: (event: Event) => boolean; 21 | count: number; 22 | } 23 | 24 | /** 25 | * A {@link Logger} that keeps track of metrics. 26 | */ 27 | export class MetricsLogger extends SimpleLogger { 28 | #startTime: [number, number] = hrtime(); 29 | readonly #metrics: [Metric, Metric, Metric, Metric] = [ 30 | { 31 | name: 'Success', 32 | // 'no-command' is technically a success, but we don't want to count it as 33 | // a success for this metric because nothing was actually run. 34 | matches: (e: Event) => e.type === 'success' && e.reason !== 'no-command', 35 | count: 0, 36 | }, 37 | { 38 | name: 'Ran', 39 | matches: (e: Event) => e.type === 'success' && e.reason === 'exit-zero', 40 | count: 0, 41 | }, 42 | { 43 | name: 'Skipped (fresh)', 44 | matches: (e: Event) => e.type === 'success' && e.reason === 'fresh', 45 | count: 0, 46 | }, 47 | { 48 | name: 'Restored from cache', 49 | matches: (e: Event) => e.type === 'success' && e.reason === 'cached', 50 | count: 0, 51 | }, 52 | ]; 53 | 54 | /** 55 | * @param rootPackage The npm package directory that the root script being 56 | * executed belongs to. 57 | */ 58 | constructor(rootPackage: string, console: Console) { 59 | super(rootPackage, console); 60 | } 61 | 62 | /** 63 | * Update relevant metrics for an event and pass it up to the parent logger. 64 | */ 65 | override log(event: Event): void { 66 | // When in watch mode, metrics should reset at the start of each run. 67 | if (event.type === 'info' && event.detail === 'watch-run-start') { 68 | this.#resetMetrics(); 69 | } 70 | 71 | this.#updateMetrics(event); 72 | super.log(event); 73 | } 74 | 75 | /** 76 | * Log the current metrics and reset the state of each metric. 77 | */ 78 | override printMetrics(): void { 79 | const successes = this.#metrics[0].count ?? 0; 80 | 81 | if (!successes) { 82 | this.#resetMetrics(); 83 | return; 84 | } 85 | 86 | const elapsed = this.#getElapsedTime(); 87 | const nameOffset = 20; 88 | 89 | const out: string[] = [ 90 | `🏁 [metrics] Executed ${successes} script(s) in ${elapsed} seconds`, 91 | ]; 92 | 93 | for (const metric of this.#metrics.slice(1)) { 94 | const name = metric.name.padEnd(nameOffset); 95 | const count = metric.count; 96 | const percent = this.#calculatePercentage(count, successes); 97 | 98 | out.push(`\t${name}: ${count} (${percent}%)`); 99 | } 100 | 101 | this.console.log(out.join('\n')); 102 | 103 | this.#resetMetrics(); 104 | } 105 | 106 | #updateMetrics(event: Event): void { 107 | for (const metric of this.#metrics) { 108 | if (metric.matches(event)) { 109 | metric.count++; 110 | } 111 | } 112 | } 113 | 114 | #resetMetrics(): void { 115 | this.#startTime = hrtime(); 116 | 117 | for (const metric of this.#metrics) { 118 | metric.count = 0; 119 | } 120 | } 121 | 122 | #getElapsedTime(): string { 123 | const [seconds, nanoseconds] = hrtime(this.#startTime); 124 | const time = seconds + nanoseconds / 1e9; 125 | return time.toFixed(2); 126 | } 127 | 128 | #calculatePercentage(numerator: number, denominator: number): number { 129 | if (denominator === 0) { 130 | return 0; 131 | } 132 | 133 | return Math.floor((numerator / denominator) * 100); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/logging/quiet-logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Event} from '../event.js'; 8 | import {Logger, Console} from './logger.js'; 9 | import { 10 | CiWriter, 11 | StatusLineWriter, 12 | WriteoverLine, 13 | } from './quiet/writeover-line.js'; 14 | import {QuietRunLogger, noChange, nothing} from './quiet/run-tracker.js'; 15 | 16 | // To prevent using the global console accidentally, we shadow it with 17 | // undefined 18 | const console = undefined; 19 | function markAsUsed(_: unknown) {} 20 | markAsUsed(console); 21 | 22 | /** 23 | * A {@link Logger} that prints less to the console. 24 | * 25 | * While running, it prints a single line of status text with information about 26 | * how the run is progressing, as well as emitting errors as they happen, 27 | * and any output from services or the root script. 28 | * 29 | * When the run is complete, it prints a one line summary of the results. 30 | */ 31 | export class QuietLogger implements Logger { 32 | readonly console: Console; 33 | #runTracker; 34 | readonly #rootPackage: string; 35 | readonly #statusLineWriter: StatusLineWriter; 36 | 37 | constructor( 38 | rootPackage: string, 39 | ourConsole: Console, 40 | statusLineWriter?: StatusLineWriter, 41 | ) { 42 | this.#rootPackage = rootPackage; 43 | this.#statusLineWriter = statusLineWriter ?? new WriteoverLine(ourConsole); 44 | this.#runTracker = new QuietRunLogger( 45 | this.#rootPackage, 46 | this.#statusLineWriter, 47 | ourConsole, 48 | ); 49 | this.console = ourConsole; 50 | } 51 | 52 | printMetrics() { 53 | this.#statusLineWriter.clearAndStopRendering(); 54 | this.#runTracker.printSummary(); 55 | } 56 | 57 | log(event: Event): void { 58 | if (event.type === 'info' && event.detail === 'watch-run-start') { 59 | this.#runTracker = this.#runTracker.makeInstanceForNextWatchRun(); 60 | } 61 | const line = this.#runTracker.getUpdatedMessageAfterEvent(event); 62 | if (line === noChange) { 63 | // nothing to do 64 | } else if (line === nothing) { 65 | this.#statusLineWriter.clearAndStopRendering(); 66 | } else { 67 | this.#statusLineWriter.updateStatusLine(line); 68 | } 69 | if (event.type === 'info' && event.detail === 'watch-run-end') { 70 | this.printMetrics(); 71 | } 72 | } 73 | 74 | getWatchLogger(): Logger { 75 | // QuietLogger doesn't need the screen-clearning behavior of the watch 76 | // logger, since in successful cases it only prints one line of output, 77 | // and in failure cases it can be nice to keep the old output around. 78 | return this; 79 | } 80 | 81 | [Symbol.dispose](): void { 82 | this.#statusLineWriter[Symbol.dispose](); 83 | this.#runTracker[Symbol.dispose](); 84 | this.console[Symbol.dispose](); 85 | } 86 | } 87 | 88 | /** 89 | * A QuietLogger that is intended to be used in CI environments and other 90 | * non-interactive environments. 91 | * 92 | * Doesn't use a spinner, updates less often, and doesn't use '/r' to writeover 93 | * the previous line. 94 | */ 95 | export class QuietCiLogger extends QuietLogger { 96 | constructor(rootPackage: string, ourConsole: Console) { 97 | super(rootPackage, ourConsole, new CiWriter(ourConsole)); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/logging/quiet/stack-map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // To prevent using the global console accidentally, we shadow it with 8 | // undefined 9 | const console = undefined; 10 | function markAsUsed(_: unknown) {} 11 | markAsUsed(console); 12 | 13 | /** 14 | * A map that can also efficiently return the most recently added entry. 15 | */ 16 | export class StackMap extends Map { 17 | readonly #stack: Array<[K, V]> = []; 18 | 19 | override set(key: K, value: V) { 20 | if (!this.has(key)) { 21 | this.#stack.push([key, value]); 22 | } 23 | return super.set(key, value); 24 | } 25 | 26 | // Surprisingly, we don't need to override delete, because we expect peek() 27 | // to be called frequently, and it will remove any trailing deleted entries. 28 | 29 | /** 30 | * Returns the most recently added entry that's still in the map, or 31 | * undefined if the map is empty. 32 | */ 33 | peek(): [K, V] | undefined { 34 | while (true) { 35 | const last = this.#stack[this.#stack.length - 1]; 36 | if (!last) { 37 | return; 38 | } 39 | if (this.has(last[0])) { 40 | return last; 41 | } 42 | this.#stack.pop(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/logging/quiet/writeover-line.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {DEBUG, Console} from '../logger.js'; 8 | import '../../util/dispose.js'; 9 | 10 | // To prevent using the global console accidentally, we shadow it with 11 | // undefined 12 | const console = undefined; 13 | function markAsUsed(_: unknown) {} 14 | markAsUsed(console); 15 | 16 | export interface StatusLineWriter extends Disposable { 17 | clearAndStopRendering(): void; 18 | updateStatusLine(line: string): void; 19 | clearUntilDisposed(): Disposable | undefined; 20 | } 21 | 22 | abstract class BaseWriteoverLine implements StatusLineWriter { 23 | #updateInterval: NodeJS.Timeout | undefined; 24 | protected _line = ''; 25 | protected _targetFps = 60; 26 | /** 27 | * If true, we write over the previous line with a \r carriage return, 28 | * otherwise we write a new line. 29 | */ 30 | protected _writeOver = !DEBUG; 31 | #disposed = false; 32 | readonly console: Console; 33 | 34 | constructor(console: Console) { 35 | this.console = console; 36 | } 37 | 38 | /** 39 | * Called periodically, so that the status line can be updated if needed. 40 | */ 41 | protected abstract _update(): void; 42 | 43 | clearAndStopRendering() { 44 | // Writeover the previous line and cancel the spinner interval. 45 | if (this.#updateInterval !== undefined) { 46 | clearInterval(this.#updateInterval); 47 | this.#updateInterval = undefined; 48 | } 49 | if (this._line !== '') { 50 | this._line = ''; 51 | this._writeLine(''); 52 | } 53 | } 54 | 55 | #previousLineLength = 0; 56 | protected _writeLine(line: string) { 57 | if (!this._writeOver) { 58 | if (line === '') { 59 | return; 60 | } 61 | this.console.stderr.write(line); 62 | this.console.stderr.write('\n'); 63 | return; 64 | } 65 | this.console.stderr.write(line); 66 | const overflow = this.#previousLineLength - line.length; 67 | if (overflow > 0) { 68 | this.console.stderr.write(' '.repeat(overflow)); 69 | } 70 | this.console.stderr.write('\r'); 71 | this.#previousLineLength = line.length; 72 | } 73 | 74 | /** 75 | * Clears the line and stops the spinner, and returns a Disposable that, once 76 | * disposed, will restore the line and restart the spinner (if the spinner 77 | * was going when clearUntilDisposed() was called). 78 | * 79 | * Note that we don't expect writeoverLine.writeLine to be called while the 80 | * Disposable is active, so we don't handle that case. We could, it just 81 | * hasn't come up yet. We'd need to have an instance variable to count how 82 | * many active Disposables there are, and only restore the line and restart 83 | * the spinner when the last one is disposed. We'd also need to short circuit 84 | * the logic in writeLine, and set aside the latest line to be written. 85 | * 86 | * Use like: 87 | * 88 | * { 89 | * using _pause = writeoverLine.clearUntilDisposed(); 90 | * // console.log, write to stdout and stderr, etc 91 | * } 92 | * // once the block ends, the writeoverLine is restored 93 | */ 94 | clearUntilDisposed(): Disposable | undefined { 95 | // already cleared, nothing to do 96 | if (this.#updateInterval === undefined) { 97 | return undefined; 98 | } 99 | const line = this._line; 100 | this.clearAndStopRendering(); 101 | return { 102 | [Symbol.dispose]: () => { 103 | this.updateStatusLine(line); 104 | }, 105 | }; 106 | } 107 | 108 | updateStatusLine(line: string) { 109 | if (this.#disposed) { 110 | return; 111 | } 112 | if (DEBUG) { 113 | if (this._line !== line) { 114 | // Ensure that every line is written immediately in debug mode 115 | this.console.stderr.write(` ${line}\n`); 116 | } 117 | } 118 | this._line = line; 119 | if (line === '') { 120 | // Writeover the previous line and cancel the spinner interval. 121 | if (this.#updateInterval !== undefined) { 122 | clearInterval(this.#updateInterval); 123 | this.#updateInterval = undefined; 124 | } 125 | this._writeLine(''); 126 | return; 127 | } 128 | if (this.#updateInterval !== undefined) { 129 | // will render on next frame 130 | return; 131 | } 132 | // render now, and then schedule future renders. 133 | if (!DEBUG) { 134 | this._update(); 135 | } 136 | // schedule future renders so the spinner stays going 137 | this.#updateInterval = setInterval(() => { 138 | if (DEBUG) { 139 | // We want to schedule an interval even in debug mode, so that tests 140 | // will still fail if we don't clean it up properly, but we don't want 141 | // to actually render anything here, since we render any new line 142 | // the moment it comes in. 143 | return; 144 | } 145 | this._update(); 146 | }, 1000 / this._targetFps); 147 | } 148 | 149 | [Symbol.dispose]() { 150 | this.#disposed = true; 151 | this.clearAndStopRendering(); 152 | } 153 | } 154 | 155 | /** 156 | * Handles displaying a single line of status text, overwriting the previously 157 | * written line, and displaying a spinner to indicate liveness. 158 | */ 159 | export class WriteoverLine extends BaseWriteoverLine { 160 | #spinner = new Spinner(); 161 | 162 | #previouslyWrittenLine: string | undefined = undefined; 163 | protected override _update() { 164 | if (this._line === this.#previouslyWrittenLine) { 165 | // just write over the spinner 166 | this.console.stderr.write(this.#spinner.nextFrame); 167 | this.console.stderr.write('\r'); 168 | return; 169 | } 170 | this.#previouslyWrittenLine = this._line; 171 | this._writeLine(`${this.#spinner.nextFrame} ${this._line}`); 172 | } 173 | } 174 | 175 | /** 176 | * Like WriteoverLine, but it updates much less frequently, just prints lines 177 | * rather doing fancy writeover, doesn't draw a spinner, and stays silent 178 | * if the status line line hasn't changed. 179 | */ 180 | export class CiWriter extends BaseWriteoverLine { 181 | constructor(console: Console) { 182 | super(console); 183 | // Don't write too much, no need to flood the CI logs. 184 | this._targetFps = 1; 185 | // GitHub seems to handle \r carraige returns the same as \n, but 186 | // we don't want to rely on that. Just print status lines on new lines. 187 | this._writeOver = false; 188 | } 189 | 190 | protected previousLine = ''; 191 | protected override _update() { 192 | if (this._line === this.previousLine) { 193 | // nothing new to log 194 | return; 195 | } 196 | this.previousLine = this._line; 197 | this._writeLine(this._line); 198 | } 199 | } 200 | 201 | const spinnerFrames = [ 202 | '⠋', 203 | '⠙', 204 | '⠹', 205 | '⠸', 206 | '⠼', 207 | '⠴', 208 | '⠦', 209 | '⠧', 210 | '⠇', 211 | '⠏', 212 | ] as const; 213 | class Spinner { 214 | #frame = 0; 215 | 216 | get nextFrame() { 217 | const frame = spinnerFrames[this.#frame]!; 218 | this.#frame = (this.#frame + 1) % spinnerFrames.length; 219 | return frame; 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/logging/watch-logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {Event} from '../event.js'; 8 | import type {Logger} from './logger.js'; 9 | 10 | // To prevent using the global console accidentally, we shadow it with 11 | // undefined 12 | const console = undefined; 13 | function markAsUsed(_: unknown) {} 14 | markAsUsed(console); 15 | 16 | /** 17 | * A logger for watch mode that avoids useless output. 18 | */ 19 | export class WatchLogger implements Logger { 20 | readonly console; 21 | readonly #actualLogger: Logger; 22 | readonly #iterationBuffer: Event[] = []; 23 | #iterationIsInteresting = 24 | /* The first iteration is always interesting. */ true; 25 | 26 | constructor(actualLogger: Logger) { 27 | this.#actualLogger = actualLogger; 28 | this.console = actualLogger.console; 29 | } 30 | 31 | log(event: Event) { 32 | if (this.#iterationIsInteresting) { 33 | // This iteration previously had an interesting event (or it's the very 34 | // first one, which we always show). 35 | this.#actualLogger.log(event); 36 | this.#actualLogger.printMetrics(); 37 | 38 | if (this.#isWatchRunEnd(event)) { 39 | this.#iterationIsInteresting = false; 40 | } 41 | } else if (this.#isWatchRunEnd(event)) { 42 | // We finished a watch iteration and nothing interesting ever happened. 43 | // Discard the buffer. 44 | this.#iterationBuffer.length = 0; 45 | } else if (this.#isInteresting(event)) { 46 | // The first interesting event of the iteration. Flush the buffer and log 47 | // everything from now until the next iteration. 48 | while (this.#iterationBuffer.length > 0) { 49 | this.#actualLogger.log(this.#iterationBuffer.shift()!); 50 | } 51 | this.#actualLogger.log(event); 52 | this.#iterationIsInteresting = true; 53 | } else { 54 | // An uninteresting event in a thus far uninteresting iteration. 55 | this.#iterationBuffer.push(event); 56 | } 57 | } 58 | 59 | printMetrics(): void { 60 | // printMetrics() not used in watch-logger. 61 | } 62 | 63 | #isInteresting(event: Event): boolean { 64 | const code = 65 | event.type === 'output' 66 | ? event.stream 67 | : event.type === 'info' 68 | ? event.detail 69 | : event.reason; 70 | switch (code) { 71 | case 'fresh': 72 | case 'no-command': 73 | case 'failed-previous-watch-iteration': 74 | case 'watch-run-start': 75 | case 'start-cancelled': 76 | case 'locked': 77 | case 'analysis-completed': { 78 | return false; 79 | } 80 | } 81 | return true; 82 | } 83 | 84 | #isWatchRunEnd(event: Event): boolean { 85 | return event.type === 'info' && event.detail === 'watch-run-end'; 86 | } 87 | 88 | [Symbol.dispose](): void { 89 | this.#actualLogger[Symbol.dispose](); 90 | this.console[Symbol.dispose](); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/test/analysis.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {suite} from 'uvu'; 8 | import * as assert from 'uvu/assert'; 9 | import {rigTest} from './util/rig-test.js'; 10 | import {Analyzer} from '../analyzer.js'; 11 | 12 | const test = suite(); 13 | 14 | test( 15 | 'analyzes services', 16 | rigTest(async ({rig}) => { 17 | // a 18 | // / | \ 19 | // | v v 20 | // | c d 21 | // | / | 22 | // b <-+ | 23 | // v 24 | // e 25 | await rig.write({ 26 | 'package.json': { 27 | scripts: { 28 | a: 'wireit', 29 | b: 'wireit', 30 | c: 'wireit', 31 | d: 'wireit', 32 | e: 'wireit', 33 | }, 34 | wireit: { 35 | a: { 36 | dependencies: ['b', 'c', 'd'], 37 | }, 38 | b: { 39 | command: 'true', 40 | service: true, 41 | }, 42 | c: { 43 | command: 'true', 44 | service: true, 45 | }, 46 | d: { 47 | command: 'true', 48 | dependencies: ['b', 'e'], 49 | }, 50 | e: { 51 | command: 'true', 52 | service: true, 53 | }, 54 | }, 55 | }, 56 | }); 57 | 58 | const analyzer = new Analyzer('npm'); 59 | const result = await analyzer.analyze( 60 | {packageDir: rig.temp, name: 'a'}, 61 | [], 62 | ); 63 | if (!result.config.ok) { 64 | console.log(result.config.error); 65 | throw new Error('Not ok'); 66 | } 67 | 68 | // a 69 | const a = result.config.value; 70 | assert.equal(a.name, 'a'); 71 | if (a.command) { 72 | throw new Error('Expected no-command'); 73 | } 74 | assert.equal(a.dependencies.length, 3); 75 | 76 | // b 77 | const b = a.dependencies[0]!.config; 78 | assert.equal(b.name, 'b'); 79 | if (!b.service) { 80 | throw new Error('Expected service'); 81 | } 82 | assert.equal(b.serviceConsumers.length, 1); 83 | assert.equal(b.serviceConsumers[0]!.name, 'd'); 84 | assert.equal(b.isPersistent, true); 85 | 86 | // c 87 | const c = a.dependencies[1]!.config; 88 | assert.equal(c.name, 'c'); 89 | if (!c.service) { 90 | throw new Error('Expected service'); 91 | } 92 | assert.equal(c.isPersistent, true); 93 | assert.equal(c.serviceConsumers.length, 0); 94 | assert.equal(c.services.length, 0); 95 | 96 | // d 97 | const d = a.dependencies[2]!.config; 98 | assert.equal(d.name, 'd'); 99 | assert.equal(d.services.length, 2); 100 | assert.equal(d.services[0]!.name, 'b'); 101 | assert.equal(d.services[1]!.name, 'e'); 102 | 103 | // e 104 | const e = d.services[1]!; 105 | assert.equal(e.name, 'e'); 106 | if (!e.service) { 107 | throw new Error('Expected service'); 108 | } 109 | assert.equal(e.isPersistent, false); 110 | assert.equal(e.serviceConsumers.length, 1); 111 | }), 112 | ); 113 | 114 | test( 115 | '.wireit/, .git/, and node_modules/ are automatically ' + 116 | 'excluded from input and output files by default', 117 | rigTest(async ({rig}) => { 118 | await rig.write({ 119 | 'package.json': { 120 | scripts: { 121 | build: 'wireit', 122 | }, 123 | wireit: { 124 | build: { 125 | command: 'true', 126 | files: ['**/*.ts'], 127 | output: ['**/*.js'], 128 | // Don't also automatically add package-lock.json paths as input 129 | // files, to make this test simpler/more focused. 130 | packageLocks: [], 131 | }, 132 | }, 133 | }, 134 | }); 135 | 136 | const analyzer = new Analyzer('npm'); 137 | const result = await analyzer.analyze( 138 | { 139 | packageDir: rig.temp, 140 | name: 'build', 141 | }, 142 | [], 143 | ); 144 | if (!result.config.ok) { 145 | console.log(result.config.error); 146 | throw new Error('Not ok'); 147 | } 148 | 149 | const withDefaultExcludes = result.config.value; 150 | assert.equal(withDefaultExcludes.files?.values, [ 151 | '**/*.ts', 152 | '!.git/', 153 | '!.hg/', 154 | '!.svn/', 155 | '!.wireit/', 156 | '!.yarn/', 157 | '!CVS/', 158 | '!node_modules/', 159 | ]); 160 | assert.equal(withDefaultExcludes.output?.values, [ 161 | '**/*.js', 162 | '!.git/', 163 | '!.hg/', 164 | '!.svn/', 165 | '!.wireit/', 166 | '!.yarn/', 167 | '!CVS/', 168 | '!node_modules/', 169 | ]); 170 | }), 171 | ); 172 | 173 | test( 174 | 'Default excluded paths are not present when ' + 175 | 'allowUsuallyExcludedPaths is true', 176 | rigTest(async ({rig}) => { 177 | await rig.write({ 178 | 'package.json': { 179 | scripts: { 180 | build: 'wireit', 181 | }, 182 | wireit: { 183 | build: { 184 | command: 'true', 185 | files: ['**/*.ts'], 186 | output: ['**/*.js'], 187 | // Don't also automatically add package-lock.json paths as input 188 | // files, to make this test simpler/more focused. 189 | packageLocks: [], 190 | allowUsuallyExcludedPaths: true, 191 | }, 192 | }, 193 | }, 194 | }); 195 | 196 | const analyzer = new Analyzer('npm'); 197 | const result = await analyzer.analyze( 198 | { 199 | packageDir: rig.temp, 200 | name: 'build', 201 | }, 202 | [], 203 | ); 204 | if (!result.config.ok) { 205 | console.log(result.config.error); 206 | throw new Error('Not ok'); 207 | } 208 | 209 | const build = result.config.value; 210 | assert.equal(build.files?.values, ['**/*.ts']); 211 | assert.equal(build.output?.values, ['**/*.js']); 212 | }), 213 | ); 214 | 215 | test( 216 | 'Default excluded paths are not present when files and output are empty', 217 | rigTest(async ({rig}) => { 218 | await rig.write({ 219 | 'package.json': { 220 | scripts: { 221 | build: 'wireit', 222 | }, 223 | wireit: { 224 | build: { 225 | command: 'true', 226 | files: [], 227 | output: [], 228 | packageLocks: [], 229 | }, 230 | }, 231 | }, 232 | }); 233 | 234 | const analyzer = new Analyzer('npm'); 235 | const result = await analyzer.analyze( 236 | { 237 | packageDir: rig.temp, 238 | name: 'build', 239 | }, 240 | [], 241 | ); 242 | if (!result.config.ok) { 243 | console.log(result.config.error); 244 | throw new Error('Not ok'); 245 | } 246 | 247 | const build = result.config.value; 248 | assert.equal(build.files?.values, []); 249 | assert.equal(build.output?.values, []); 250 | }), 251 | ); 252 | 253 | test.run(); 254 | -------------------------------------------------------------------------------- /src/test/cache-github-real.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2025 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {suite} from 'uvu'; 8 | import {registerCommonCacheTests} from './cache-common.js'; 9 | import {WireitTestRig} from './util/test-rig.js'; 10 | 11 | const test = suite<{rig: WireitTestRig}>(); 12 | 13 | test.before.each(async (ctx) => { 14 | try { 15 | ctx.rig = new WireitTestRig(); 16 | ctx.rig.env = { 17 | ...ctx.rig.env, 18 | WIREIT_CACHE: 'github', 19 | // We're testing against the actual production GitHub API, so we must pass 20 | // down access to the real credentials (normally our test rig removes any 21 | // WIREIT_ variables from being inherited). 22 | WIREIT_CACHE_GITHUB_CUSTODIAN_PORT: 23 | process.env.WIREIT_CACHE_GITHUB_CUSTODIAN_PORT, 24 | }; 25 | await ctx.rig.setup(); 26 | } catch (error) { 27 | // Uvu has a bug where it silently ignores failures in before and after, 28 | // see https://github.com/lukeed/uvu/issues/191. 29 | console.error('uvu before error', error); 30 | process.exit(1); 31 | } 32 | }); 33 | 34 | test.after.each(async (ctx) => { 35 | try { 36 | await ctx.rig.cleanup(); 37 | } catch (error) { 38 | // Uvu has a bug where it silently ignores failures in before and after, 39 | // see https://github.com/lukeed/uvu/issues/191. 40 | console.error('uvu after error', error); 41 | process.exit(1); 42 | } 43 | }); 44 | 45 | registerCommonCacheTests(test, 'github'); 46 | 47 | test.run(); 48 | -------------------------------------------------------------------------------- /src/test/cache-local.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {suite} from 'uvu'; 8 | import {registerCommonCacheTests} from './cache-common.js'; 9 | 10 | const test = suite(); 11 | 12 | registerCommonCacheTests(test, 'local'); 13 | 14 | test.run(); 15 | -------------------------------------------------------------------------------- /src/test/delete.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {suite} from 'uvu'; 8 | import * as assert from 'uvu/assert'; 9 | import {FilesystemTestRig} from './util/filesystem-test-rig.js'; 10 | import * as pathlib from 'path'; 11 | import {shuffle} from '../util/shuffle.js'; 12 | import {windowsifyPathIfOnWindows} from './util/windows.js'; 13 | import {deleteEntries} from '../util/delete.js'; 14 | 15 | import type {AbsoluteEntry} from '../util/glob.js'; 16 | 17 | const test = suite<{ 18 | rig: FilesystemTestRig; 19 | 20 | /** Make a fake glob AbsoluteEntry that looks like a regular file. */ 21 | file: (path: string) => AbsoluteEntry; 22 | 23 | /** Make a fake glob AbsoluteEntry that looks like a directory. */ 24 | dir: (path: string) => AbsoluteEntry; 25 | 26 | /** Make a fake glob Entry that looks like a symlink. */ 27 | symlink: (path: string) => AbsoluteEntry; 28 | }>(); 29 | 30 | test.before.each(async (ctx) => { 31 | try { 32 | const rig = (ctx.rig = new FilesystemTestRig()); 33 | await rig.setup(); 34 | 35 | ctx.file = (path) => 36 | ({ 37 | path: windowsifyPathIfOnWindows(pathlib.join(rig.temp, path)), 38 | dirent: { 39 | isFile: () => true, 40 | isDirectory: () => false, 41 | isSymbolicLink: () => false, 42 | }, 43 | }) as AbsoluteEntry; 44 | 45 | ctx.dir = (path) => 46 | ({ 47 | path: windowsifyPathIfOnWindows(pathlib.join(rig.temp, path)), 48 | dirent: { 49 | isFile: () => false, 50 | isDirectory: () => true, 51 | isSymbolicLink: () => false, 52 | }, 53 | }) as AbsoluteEntry; 54 | 55 | ctx.symlink = (path) => 56 | ({ 57 | path: windowsifyPathIfOnWindows(pathlib.join(rig.temp, path)), 58 | dirent: { 59 | isFile: () => false, 60 | isDirectory: () => false, 61 | isSymbolicLink: () => true, 62 | }, 63 | }) as AbsoluteEntry; 64 | } catch (error) { 65 | // Uvu has a bug where it silently ignores failures in before and after, 66 | // see https://github.com/lukeed/uvu/issues/191. 67 | console.error('uvu before error', error); 68 | process.exit(1); 69 | } 70 | }); 71 | 72 | test.after.each(async (ctx) => { 73 | try { 74 | await ctx.rig.cleanup(); 75 | } catch (error) { 76 | // Uvu has a bug where it silently ignores failures in before and after, 77 | // see https://github.com/lukeed/uvu/issues/191. 78 | console.error('uvu after error', error); 79 | process.exit(1); 80 | } 81 | }); 82 | 83 | test('ignore empty entries', async () => { 84 | await deleteEntries([]); 85 | }); 86 | 87 | test('delete 1 file', async ({rig, file}) => { 88 | await rig.touch('foo'); 89 | await deleteEntries([file('foo')]); 90 | assert.not(await rig.exists('foo')); 91 | }); 92 | 93 | test('ignore non-existent file', async ({rig, file}) => { 94 | await deleteEntries([file('foo')]); 95 | assert.not(await rig.exists('foo')); 96 | }); 97 | 98 | test('delete 1 directory', async ({rig, dir}) => { 99 | await rig.mkdir('foo'); 100 | await deleteEntries([dir('foo')]); 101 | assert.not(await rig.exists('foo')); 102 | }); 103 | 104 | test('ignore non-existent directory', async ({rig, dir}) => { 105 | await deleteEntries([dir('foo')]); 106 | assert.not(await rig.exists('foo')); 107 | }); 108 | 109 | test('delete 1 directory and its 1 file', async ({rig, file, dir}) => { 110 | await rig.mkdir('foo'); 111 | await rig.touch('foo/bar'); 112 | await deleteEntries([file('foo/bar'), dir('foo')]); 113 | assert.not(await rig.exists('foo/bar')); 114 | assert.not(await rig.exists('foo')); 115 | }); 116 | 117 | test('ignore non-empty directory', async ({rig, dir}) => { 118 | await rig.mkdir('foo'); 119 | await rig.touch('foo/bar'); 120 | await deleteEntries([dir('foo')]); 121 | assert.ok(await rig.exists('foo/bar')); 122 | assert.ok(await rig.exists('foo')); 123 | }); 124 | 125 | test('delete child directory but not parent', async ({rig, dir}) => { 126 | await rig.mkdir('foo/bar'); 127 | await deleteEntries([dir('foo/bar')]); 128 | assert.not(await rig.exists('foo/bar')); 129 | assert.ok(await rig.exists('foo')); 130 | }); 131 | 132 | test('grandparent and child scheduled for delete, but not parent', async ({ 133 | rig, 134 | dir, 135 | }) => { 136 | await rig.mkdir('foo/bar/baz'); 137 | await deleteEntries([dir('foo'), dir('foo/bar/baz')]); 138 | assert.not(await rig.exists('foo/bar/baz')); 139 | assert.ok(await rig.exists('foo')); 140 | assert.ok(await rig.exists('foo/bar')); 141 | }); 142 | 143 | test('delete child directories before parents', async ({rig, dir}) => { 144 | await rig.mkdir('a/b/c/d'); 145 | const entries = [dir('a/b/c'), dir('a'), dir('a/b/c/d'), dir('a/b')]; 146 | await deleteEntries(entries); 147 | assert.not(await rig.exists('a/b/c/d')); 148 | assert.not(await rig.exists('a/b/c')); 149 | assert.not(await rig.exists('a/b')); 150 | assert.not(await rig.exists('a')); 151 | }); 152 | 153 | test('delete symlink to existing file but not its target', async ({ 154 | rig, 155 | symlink, 156 | }) => { 157 | await rig.write('target', 'content'); 158 | await rig.symlink('target', 'symlink', 'file'); 159 | const entries = [symlink('symlink')]; 160 | await deleteEntries(entries); 161 | assert.not(await rig.exists('symlink')); 162 | assert.equal(await rig.read('target'), 'content'); 163 | }); 164 | 165 | test('delete symlink to existing directory but not its target', async ({ 166 | rig, 167 | symlink, 168 | }) => { 169 | await rig.mkdir('target'); 170 | await rig.symlink('target', 'symlink', 'dir'); 171 | const entries = [symlink('symlink')]; 172 | await deleteEntries(entries); 173 | assert.not(await rig.exists('symlink')); 174 | assert.ok(await rig.isDirectory('target')); 175 | }); 176 | 177 | test('delete symlink to non-existing file', async ({rig, symlink}) => { 178 | await rig.symlink('target', 'symlink', 'file'); 179 | const entries = [symlink('symlink')]; 180 | await deleteEntries(entries); 181 | assert.not(await rig.exists('symlink')); 182 | }); 183 | 184 | test('stress test', async ({rig, file, dir}) => { 185 | const numRoots = 10; 186 | const depthPerRoot = 10; 187 | const filesPerDir = 300; 188 | 189 | // Generate a nested file tree. 190 | // E.g. with numRoots = 2, depthPerRoot = 2, filesPerDir = 2: 191 | // 192 | // 193 | // ├── r0 194 | // │ └── d0 195 | // │ ├── d1 196 | // │ │ ├── f0 197 | // │ │ └── f1 198 | // │ ├── f0 199 | // │ └── f1 200 | // └── r1 201 | // └── d0 202 | // ├── d1 203 | // │ ├── f0 204 | // │ └── f1 205 | // ├── f0 206 | // └── f1 207 | 208 | const entries = []; 209 | let dirPath = ''; 210 | for (let r = 0; r < numRoots; r++) { 211 | dirPath = `r${r}`; 212 | entries.push(dir(dirPath)); 213 | for (let d = 0; d < depthPerRoot; d++) { 214 | dirPath = pathlib.join(dirPath, `d${d}`); 215 | entries.push(dir(dirPath)); 216 | for (let f = 0; f < filesPerDir; f++) { 217 | const filePath = pathlib.join(dirPath, `f${f}`); 218 | entries.push(file(filePath)); 219 | await rig.touch(filePath); 220 | } 221 | } 222 | } 223 | 224 | shuffle(entries); 225 | await deleteEntries(entries); 226 | await Promise.all( 227 | entries.map(async (entry) => assert.not(await rig.exists(entry.path))), 228 | ); 229 | }); 230 | 231 | test.run(); 232 | -------------------------------------------------------------------------------- /src/test/diagnostic.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {test} from 'uvu'; 8 | import * as assert from 'uvu/assert'; 9 | import {drawSquiggle, OffsetToPositionConverter, Position} from '../error.js'; 10 | import {removeAnsiColors} from './util/colors.js'; 11 | 12 | function assertSquiggleAndPosition( 13 | { 14 | offset, 15 | length, 16 | contents, 17 | indent, 18 | }: {offset: number; length: number; contents: string; indent?: number}, 19 | expectedSquiggle: string, 20 | expectedPosition: Position, 21 | ) { 22 | const squiggle = drawSquiggle( 23 | { 24 | range: {offset, length}, 25 | file: { 26 | path: 'package.json', 27 | contents, 28 | }, 29 | }, 30 | indent ?? 0, 31 | ); 32 | const position = 33 | OffsetToPositionConverter.createUncachedForTest(contents).toPosition( 34 | offset, 35 | ); 36 | if (expectedSquiggle[0] !== '\n') { 37 | throw new Error( 38 | `Test authoring error: write the expected squiggle as a template string with a leading newline.`, 39 | ); 40 | } 41 | assert.equal(removeAnsiColors(squiggle), expectedSquiggle.slice(1)); 42 | assert.equal(position, expectedPosition); 43 | } 44 | 45 | test('drawing squiggles under ranges in single-line files', () => { 46 | assertSquiggleAndPosition( 47 | { 48 | offset: 0, 49 | length: 0, 50 | contents: 'H', 51 | }, 52 | ` 53 | H 54 | `, 55 | {line: 1, character: 1}, 56 | ); 57 | 58 | assertSquiggleAndPosition( 59 | { 60 | offset: 3, 61 | length: 3, 62 | contents: 'aaabbbccc', 63 | }, 64 | ` 65 | aaabbbccc 66 | ~~~`, 67 | {line: 1, character: 4}, 68 | ); 69 | 70 | assertSquiggleAndPosition( 71 | { 72 | offset: 3, 73 | length: 3, 74 | contents: 'aaabbbccc', 75 | indent: 8, 76 | }, 77 | ` 78 | aaabbbccc 79 | ~~~`, 80 | {line: 1, character: 4}, 81 | ); 82 | }); 83 | 84 | test('drawing squiggles single-line ranges at the end of multi-line files', () => { 85 | assertSquiggleAndPosition( 86 | { 87 | offset: 4, 88 | length: 0, 89 | contents: 'abc\nH\n', 90 | }, 91 | ` 92 | H 93 | `, 94 | {line: 2, character: 1}, 95 | ); 96 | 97 | assertSquiggleAndPosition( 98 | { 99 | offset: 4, 100 | length: 1, 101 | contents: 'abc\nH\n', 102 | }, 103 | ` 104 | H 105 | ~`, 106 | {line: 2, character: 1}, 107 | ); 108 | 109 | assertSquiggleAndPosition( 110 | { 111 | offset: 4, 112 | length: 1, 113 | contents: 'abc\nH\n', 114 | }, 115 | ` 116 | H 117 | ~`, 118 | {line: 2, character: 1}, 119 | ); 120 | 121 | assertSquiggleAndPosition( 122 | {offset: 7, length: 3, contents: 'abc\naaabbbccc'}, 123 | ` 124 | aaabbbccc 125 | ~~~`, 126 | {line: 2, character: 4}, 127 | ); 128 | 129 | assertSquiggleAndPosition( 130 | { 131 | offset: 7, 132 | length: 3, 133 | contents: 'abc\naaabbbccc', 134 | indent: 8, 135 | }, 136 | 137 | ` 138 | aaabbbccc 139 | ~~~`, 140 | {line: 2, character: 4}, 141 | ); 142 | }); 143 | 144 | test('drawing squiggles under multi-line ranges', () => { 145 | assertSquiggleAndPosition( 146 | { 147 | offset: 0, 148 | length: 0, 149 | contents: 'H\nabc', 150 | }, 151 | ` 152 | H 153 | `, 154 | {line: 1, character: 1}, 155 | ); 156 | 157 | assertSquiggleAndPosition( 158 | { 159 | offset: 0, 160 | length: 1, 161 | contents: 'H\nabc', 162 | }, 163 | ` 164 | H 165 | ~`, 166 | {line: 1, character: 1}, 167 | ); 168 | 169 | assertSquiggleAndPosition( 170 | { 171 | offset: 3, 172 | length: 3, 173 | contents: 'aaabbbccc\nabc', 174 | }, 175 | ` 176 | aaabbbccc 177 | ~~~`, 178 | {line: 1, character: 4}, 179 | ); 180 | 181 | assertSquiggleAndPosition( 182 | { 183 | offset: 3, 184 | length: 3, 185 | contents: 'aaabbbccc\nabc', 186 | indent: 8, 187 | }, 188 | ` 189 | aaabbbccc 190 | ~~~`, 191 | {line: 1, character: 4}, 192 | ); 193 | }); 194 | 195 | test('drawing squiggles under one line of a multi-line input', () => { 196 | assertSquiggleAndPosition( 197 | {offset: 0, length: 0, contents: 'abc\ndef\nhij'}, 198 | ` 199 | abc 200 | `, 201 | {line: 1, character: 1}, 202 | ); 203 | 204 | assertSquiggleAndPosition( 205 | {offset: 0, length: 5, contents: 'abc\ndef\nhij'}, 206 | ` 207 | abc 208 | ~~~ 209 | def 210 | ~`, 211 | {line: 1, character: 1}, 212 | ); 213 | 214 | // include the newline at the end of the first line 215 | assertSquiggleAndPosition( 216 | {offset: 0, length: 4, contents: 'abc\ndef\nhij'}, 217 | ` 218 | abc 219 | ~~~ 220 | def 221 | `, 222 | {line: 1, character: 1}, 223 | ); 224 | 225 | // include _only_ the newline at the end of the first line 226 | assertSquiggleAndPosition( 227 | {offset: 3, length: 1, contents: 'abc\ndef\nhij'}, 228 | ` 229 | abc 230 | ${' '} 231 | def 232 | `, 233 | {line: 1, character: 4}, 234 | ); 235 | 236 | assertSquiggleAndPosition( 237 | {offset: 3, length: 2, contents: 'abc\ndef\nhij'}, 238 | ` 239 | abc 240 | ${' '} 241 | def 242 | ~`, 243 | {line: 1, character: 4}, 244 | ); 245 | 246 | assertSquiggleAndPosition( 247 | {offset: 2, length: 7, contents: 'abc\ndef\nhij'}, 248 | ` 249 | abc 250 | ~ 251 | def 252 | ~~~ 253 | hij 254 | ~`, 255 | {line: 1, character: 3}, 256 | ); 257 | }); 258 | 259 | test.run(); 260 | -------------------------------------------------------------------------------- /src/test/fs.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Semaphore} from '../util/fs.js'; 8 | import {test} from 'uvu'; 9 | import * as assert from 'uvu/assert'; 10 | 11 | async function wait(ms: number): Promise { 12 | return new Promise((resolve) => setTimeout(resolve, ms)); 13 | } 14 | 15 | test('Semaphore restricts resource access', async () => { 16 | const semaphore = new Semaphore(1); 17 | const reservation1 = await semaphore.reserve(); 18 | const reservation2Promise = semaphore.reserve(); 19 | let hasResolved = false; 20 | void reservation2Promise.then(() => { 21 | hasResolved = true; 22 | }); 23 | // Wait a bit to make sure the promise has had a chance to resolve. 24 | await wait(100); 25 | // The semaphore doesn't let the second reservation happen yet, it would 26 | // be over budget. 27 | assert.is(hasResolved, false); 28 | reservation1[Symbol.dispose](); 29 | // Now it can happen. 30 | await reservation2Promise; 31 | assert.is(hasResolved, true); 32 | }); 33 | 34 | test('Semaphore reservation happens immediately when not under contention', async () => { 35 | const semaphore = new Semaphore(3); 36 | await semaphore.reserve(); 37 | await semaphore.reserve(); 38 | await semaphore.reserve(); 39 | // If the test finishes, then we were able to reserve three slots. 40 | }); 41 | 42 | test.run(); 43 | -------------------------------------------------------------------------------- /src/test/json-schema.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as pathlib from 'path'; 8 | import * as assert from 'uvu/assert'; 9 | import * as jsonSchema from 'jsonschema'; 10 | import {suite} from 'uvu'; 11 | import * as fs from 'fs'; 12 | import * as url from 'url'; 13 | 14 | import type {PackageJson} from './util/package-json.js'; 15 | 16 | const schema = JSON.parse( 17 | fs.readFileSync( 18 | pathlib.join( 19 | url.fileURLToPath(import.meta.url), 20 | '..', 21 | '..', 22 | '..', 23 | 'schema.json', 24 | ), 25 | 'utf-8', 26 | ), 27 | ) as jsonSchema.Schema; 28 | const validator = new jsonSchema.Validator(); 29 | validator.addSchema(schema); 30 | 31 | function shouldValidate(packageJson: PackageJson) { 32 | expectValidationErrors(packageJson, []); 33 | } 34 | 35 | function expectValidationErrors(packageJson: object, errors: string[]) { 36 | const validationResult = validator.validate(packageJson, schema); 37 | assert.equal( 38 | validationResult.errors.map((e) => e.toString()), 39 | errors, 40 | ); 41 | } 42 | 43 | const test = suite(); 44 | 45 | test('an empty package.json file is valid', () => { 46 | shouldValidate({}); 47 | }); 48 | 49 | test('an empty wireit config section is valid', () => { 50 | shouldValidate({wireit: {}}); 51 | }); 52 | 53 | test('a script with just a command is valid', () => { 54 | shouldValidate({wireit: {a: {command: 'b'}}}); 55 | }); 56 | 57 | test('a script with just dependencies is valid', () => { 58 | shouldValidate({wireit: {a: {dependencies: ['b']}}}); 59 | }); 60 | 61 | test('dependency object is valid', () => { 62 | shouldValidate({wireit: {a: {dependencies: [{script: 'b'}]}}}); 63 | }); 64 | 65 | test('dependency object with cascade:false annotation is valid', () => { 66 | shouldValidate({ 67 | wireit: {a: {dependencies: [{script: 'b', cascade: false}]}}, 68 | }); 69 | }); 70 | 71 | // I couldn't figure out how to make this test pass while keeping the other 72 | // error messages reasonable. 73 | // It just turned all errors into this one. 74 | test.skip('an empty script is invalid', () => { 75 | expectValidationErrors({wireit: {a: {}}}, [ 76 | 'instance.wireit.a is not any of ,', 77 | ]); 78 | }); 79 | 80 | test('a script with all fields set is valid', () => { 81 | shouldValidate({ 82 | wireit: { 83 | a: { 84 | command: 'b', 85 | dependencies: ['c', {script: 'c', cascade: false}], 86 | files: ['d'], 87 | output: ['e'], 88 | clean: true, 89 | packageLocks: ['f'], 90 | }, 91 | }, 92 | }); 93 | }); 94 | 95 | test('clean can be either a boolean or the string if-file-deleted', () => { 96 | shouldValidate({ 97 | wireit: { 98 | a: { 99 | command: 'b', 100 | clean: true, 101 | }, 102 | }, 103 | }); 104 | shouldValidate({ 105 | wireit: { 106 | a: { 107 | command: 'b', 108 | clean: false, 109 | }, 110 | }, 111 | }); 112 | shouldValidate({ 113 | wireit: { 114 | a: { 115 | command: 'b', 116 | clean: 'if-file-deleted', 117 | }, 118 | }, 119 | }); 120 | expectValidationErrors( 121 | { 122 | wireit: { 123 | a: { 124 | command: 'b', 125 | clean: 'something else', 126 | }, 127 | }, 128 | }, 129 | [ 130 | 'instance.wireit.a.clean is not one of enum values: true,false,if-file-deleted', 131 | ], 132 | ); 133 | }); 134 | 135 | test('command must not be empty', () => { 136 | expectValidationErrors( 137 | { 138 | wireit: { 139 | a: { 140 | command: '', 141 | }, 142 | }, 143 | }, 144 | ['instance.wireit.a.command does not meet minimum length of 1'], 145 | ); 146 | }); 147 | 148 | test('dependencies[i] must not be empty', () => { 149 | expectValidationErrors( 150 | { 151 | wireit: { 152 | a: { 153 | command: 'true', 154 | dependencies: [''], 155 | }, 156 | }, 157 | }, 158 | // TODO(aomarks) Can we get a better error message? Seems like the built-in 159 | // toString() doesn't recurse, so we'd have to build the whole error message 160 | // ourselves. 161 | [ 162 | 'instance.wireit.a.dependencies[0] is not any of [subschema 0],[subschema 1]', 163 | ], 164 | ); 165 | }); 166 | 167 | test('files[i] must not be empty', () => { 168 | expectValidationErrors( 169 | { 170 | wireit: { 171 | a: { 172 | command: 'true', 173 | files: [''], 174 | }, 175 | }, 176 | }, 177 | ['instance.wireit.a.files[0] does not meet minimum length of 1'], 178 | ); 179 | }); 180 | 181 | test('output[i] must not be empty', () => { 182 | expectValidationErrors( 183 | { 184 | wireit: { 185 | a: { 186 | command: 'true', 187 | output: [''], 188 | }, 189 | }, 190 | }, 191 | ['instance.wireit.a.output[0] does not meet minimum length of 1'], 192 | ); 193 | }); 194 | 195 | test('packageLocks[i] must not be empty', () => { 196 | expectValidationErrors( 197 | { 198 | wireit: { 199 | a: { 200 | command: 'true', 201 | packageLocks: [''], 202 | }, 203 | }, 204 | }, 205 | ['instance.wireit.a.packageLocks[0] does not meet minimum length of 1'], 206 | ); 207 | }); 208 | 209 | test('dependencies must be an array of strings', () => { 210 | expectValidationErrors( 211 | { 212 | wireit: { 213 | a: { 214 | command: 'b', 215 | dependencies: 'c', 216 | }, 217 | }, 218 | }, 219 | ['instance.wireit.a.dependencies is not of a type(s) array'], 220 | ); 221 | 222 | expectValidationErrors( 223 | { 224 | wireit: { 225 | a: { 226 | command: 'b', 227 | dependencies: [1], 228 | }, 229 | }, 230 | }, 231 | [ 232 | 'instance.wireit.a.dependencies[0] is not any of [subschema 0],[subschema 1]', 233 | ], 234 | ); 235 | }); 236 | 237 | test('dependencies[i].script is required', () => { 238 | expectValidationErrors( 239 | { 240 | wireit: { 241 | a: { 242 | command: 'b', 243 | dependencies: [{}], 244 | }, 245 | }, 246 | }, 247 | [ 248 | 'instance.wireit.a.dependencies[0] is not any of [subschema 0],[subschema 1]', 249 | ], 250 | ); 251 | }); 252 | 253 | test('dependencies[i].cascade must be boolean', () => { 254 | expectValidationErrors( 255 | { 256 | wireit: { 257 | a: { 258 | command: 'b', 259 | dependencies: [{script: 'b', cascade: 1}], 260 | }, 261 | }, 262 | }, 263 | [ 264 | 'instance.wireit.a.dependencies[0] is not any of [subschema 0],[subschema 1]', 265 | ], 266 | ); 267 | }); 268 | 269 | test.run(); 270 | -------------------------------------------------------------------------------- /src/test/optimize-mkdirs.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {test} from 'uvu'; 8 | import * as assert from 'uvu/assert'; 9 | import {optimizeMkdirs} from '../util/optimize-mkdirs.js'; 10 | import {shuffle} from '../util/shuffle.js'; 11 | import {windowsifyPathIfOnWindows} from './util/windows.js'; 12 | 13 | const check = (input: string[], expected: string[]) => 14 | assert.equal( 15 | optimizeMkdirs(input.map(windowsifyPathIfOnWindows)).sort(), 16 | expected.map(windowsifyPathIfOnWindows).sort(), 17 | ); 18 | 19 | test('empty', () => { 20 | check([], []); 21 | }); 22 | 23 | test('1 item', () => { 24 | check(['a'], ['a']); 25 | }); 26 | 27 | test('duplicates', () => { 28 | check(['a', 'a'], ['a']); 29 | }); 30 | 31 | test('parent and children', () => { 32 | check(['a', 'a/b', 'a/b/c'], ['a/b/c']); 33 | }); 34 | 35 | test('parent and child reversed', () => { 36 | check(['a/b/c', 'a/b', 'a'], ['a/b/c']); 37 | }); 38 | 39 | test('various shuffled cases', () => { 40 | const input = [ 41 | '', 42 | 'a/b/c', 43 | 'd/e/f', 44 | 'd/e/f', 45 | 'd/e/f', 46 | 'foo/bar/baz', 47 | 'foo/bar', 48 | 'foo', 49 | '1/2/3/4/5', 50 | '1/2/3/4/5', 51 | '1/2/3/4', 52 | '1/2/3', 53 | '1/2', 54 | '1', 55 | ]; 56 | const expected = ['', 'a/b/c', 'd/e/f', 'foo/bar/baz', '1/2/3/4/5']; 57 | for (let i = 0; i < 1000; i++) { 58 | shuffle(input); 59 | check(input, expected); 60 | } 61 | }); 62 | 63 | test.run(); 64 | -------------------------------------------------------------------------------- /src/test/quiet-logger.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {suite} from 'uvu'; 8 | import * as assert from 'uvu/assert'; 9 | import {rigTest} from './util/rig-test.js'; 10 | 11 | const test = suite(); 12 | 13 | test( 14 | 'CI logger with a dependency chain', 15 | rigTest(async ({rig}) => { 16 | // a --> b --> c 17 | rig.env.WIREIT_LOGGER = 'quiet-ci'; 18 | const cmdA = await rig.newCommand(); 19 | const cmdB = await rig.newCommand(); 20 | const cmdC = await rig.newCommand(); 21 | await rig.write({ 22 | 'package.json': { 23 | scripts: { 24 | a: 'wireit', 25 | b: 'wireit', 26 | // wireit scripts can depend on non-wireit scripts. 27 | c: cmdC.command, 28 | }, 29 | wireit: { 30 | a: { 31 | command: cmdA.command, 32 | dependencies: ['b'], 33 | }, 34 | b: { 35 | command: cmdB.command, 36 | dependencies: ['c'], 37 | }, 38 | }, 39 | }, 40 | }); 41 | const exec = rig.exec('npm run a'); 42 | await exec.waitForLog(/0% \[0 \/ 3\] \[1 running\] c/); 43 | 44 | const invC = await cmdC.nextInvocation(); 45 | invC.stdout('c stdout'); 46 | invC.stderr('c stderr'); 47 | invC.exit(0); 48 | await exec.waitForLog(/33% \[1 \/ 3\] \[1 running\] b/); 49 | 50 | const invB = await cmdB.nextInvocation(); 51 | invB.stdout('b stdout'); 52 | invB.stderr('b stderr'); 53 | invB.exit(0); 54 | await exec.waitForLog(/67% \[2 \/ 3\] \[1 running\] a/); 55 | 56 | const invA = await cmdA.nextInvocation(); 57 | invA.stdout('a stdout\n'); 58 | // immediately logged, because it's the root command 59 | await exec.waitForLog(/a stdout/); 60 | invA.stderr('a stderr\n'); 61 | await exec.waitForLog(/a stderr/); 62 | invA.exit(0); 63 | 64 | const res = await exec.exit; 65 | assert.equal(res.code, 0); 66 | assert.equal(cmdA.numInvocations, 1); 67 | assert.equal(cmdB.numInvocations, 1); 68 | assert.equal(cmdC.numInvocations, 1); 69 | assert.match(res.stdout, 'a stdout\n'); 70 | assert.match(res.stdout, /Ran 3 scripts and skipped 0/s); 71 | assertEndsWith( 72 | res.stderr.trim(), 73 | ` 74 | 0% [0 / 3] [1 running] c 75 | 33% [1 / 3] [1 running] b 76 | 67% [2 / 3] [1 running] a 77 | a stderr 78 | `.trim(), 79 | ); 80 | }), 81 | ); 82 | 83 | function assertEndsWith(actual: string, expected: string) { 84 | const actualSuffix = actual.slice(-expected.length); 85 | assert.equal(actualSuffix, expected); 86 | } 87 | 88 | test.run(); 89 | -------------------------------------------------------------------------------- /src/test/schema-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../schema.json", 3 | "#comment": "This is a file for manually playing with editors' support for editing a package.json file with our json schema loaded. So far, we've only tested with VSCode.", 4 | "wireit": { 5 | "build": { 6 | "dependencies": ["build:ts"] 7 | }, 8 | "build:ts": { 9 | "output": ["lib/**/*", "!lib/bundle.js"], 10 | "files": ["src/**/*.ts"], 11 | "command": "tsc", 12 | "clean": true 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test/util/check-script-output.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as assert from 'uvu/assert'; 8 | import {removeAnsiColors} from './colors.js'; 9 | import {NODE_MAJOR_VERSION} from './node-version.js'; 10 | 11 | /** 12 | * Remove ANSI colors and \r writeover lines and compare the final output as 13 | * the user would see it in their scrollback to the expected output. 14 | * 15 | * In Node 14 and earlier there's a bunch of additional chatter in the output, 16 | * so we use a sloppier comparison there. 17 | */ 18 | export function checkScriptOutput( 19 | actual: string, 20 | expected: string, 21 | message?: string, 22 | ) { 23 | actual = removeOverwrittenLines(removeAnsiColors(actual)).trim(); 24 | expected = expected.trim(); 25 | if (actual !== expected) { 26 | for (let i = 0; i < actual.length; i++) { 27 | if (actual[i] !== expected[i]) { 28 | console.log(`${i}: ${actual[i]} !== ${expected[i]}`); 29 | break; 30 | } 31 | } 32 | 33 | console.log(`Copy-pastable output:\n${actual}`); 34 | for (let i = 0; i < actual.length; i++) { 35 | if (actual[i] !== expected[i]) { 36 | console.log(`${i}: ${actual[i]} !== ${expected[i]}`); 37 | break; 38 | } 39 | } 40 | } 41 | const assertOutputEqualish = 42 | NODE_MAJOR_VERSION < 16 ? assert.match : assert.equal; 43 | assertOutputEqualish(actual, expected, message); 44 | } 45 | 46 | /** 47 | * Remove content that's overwritten with a \r 48 | */ 49 | function removeOverwrittenLines(output: string) { 50 | const lines = output.split('\n'); 51 | const result = []; 52 | for (const line of lines) { 53 | let content = ''; 54 | const splits = line.split('\r'); 55 | for (const split of splits) { 56 | // This split overrides the content up to its length, and then 57 | // any additional content from the previous line is retained. 58 | content = split + content.slice(split.length).trimEnd(); 59 | } 60 | result.push(content); 61 | } 62 | return result.join('\n'); 63 | } 64 | -------------------------------------------------------------------------------- /src/test/util/cli-options-test-binary.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // This file is a binary that is used to directly test command-line option 8 | // parsing. 9 | 10 | import {Options, getOptions} from '../../cli-options.js'; 11 | import {writeFileSync} from 'fs'; 12 | import {Result} from '../../error.js'; 13 | 14 | type SerializableOptions = Omit & { 15 | logger: string; 16 | }; 17 | 18 | const options = (await getOptions()) as unknown as Result; 19 | if (options.ok) { 20 | options.value.logger = options.value.logger.constructor.name; 21 | } 22 | writeFileSync('options.json', JSON.stringify(options)); 23 | -------------------------------------------------------------------------------- /src/test/util/cmd-shim.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // TODO(aomarks) Update the types at 8 | // https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/cmd-shim, 9 | // which still have an older callback API, instead of the newer Promise API. 10 | 11 | declare module 'cmd-shim' { 12 | export default function cmdShim(from: string, to: string): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/test/util/colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export function removeAnsiColors(s: string): string { 8 | // eslint-disable-next-line no-control-regex 9 | return s.replace(/\x1b\[\d+m/g, ''); 10 | } 11 | -------------------------------------------------------------------------------- /src/test/util/graceful-fs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * Try running the given async function, but if it fails with a possibly 9 | * transient filesystem error (like `EBUSY`), then retry a few times with 10 | * exponential(ish) backoff. 11 | */ 12 | export async function gracefulFs(fn: () => Promise): Promise { 13 | try { 14 | return await fn(); 15 | } catch (error: unknown) { 16 | if (!isRetryableFsError(error)) { 17 | throw error; 18 | } 19 | } 20 | let finalError: unknown; 21 | for (const sleep of [10, 100, 500, 1000]) { 22 | await new Promise((resolve) => setTimeout(resolve, sleep)); 23 | try { 24 | return await fn(); 25 | } catch (error: unknown) { 26 | if (!isRetryableFsError(error)) { 27 | throw error; 28 | } 29 | finalError = error; 30 | } 31 | } 32 | throw finalError; 33 | } 34 | 35 | function isRetryableFsError(error: unknown): boolean { 36 | const code = (error as {code?: string}).code; 37 | return code === 'EBUSY'; 38 | } 39 | -------------------------------------------------------------------------------- /src/test/util/node-version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | export const NODE_MAJOR_VERSION = Number(process.version.match(/\d+/)![0]); 8 | -------------------------------------------------------------------------------- /src/test/util/package-json.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * A raw package.json JSON object, including the special "wireit" section. 9 | * 10 | * Useful when writing tests with valid input. 11 | */ 12 | export interface PackageJson { 13 | name?: string; 14 | version?: string; 15 | scripts?: {[scriptName: string]: string}; 16 | wireit?: { 17 | [scriptName: string]: { 18 | command?: string; 19 | dependencies?: Array; 20 | files?: string[]; 21 | output?: string[]; 22 | clean?: boolean | 'if-file-deleted'; 23 | packageLocks?: string[]; 24 | }; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/test/util/rig-test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type * as uvu from 'uvu'; 8 | import {WireitTestRig} from './test-rig.js'; 9 | 10 | export const DEFAULT_UVU_TIMEOUT = Number(process.env.TEST_TIMEOUT ?? 60_000); 11 | 12 | /** 13 | * Returns a promise that resolves after the given period of time. 14 | */ 15 | export const wait = async (ms: number) => 16 | new Promise((resolve) => setTimeout(resolve, ms)); 17 | 18 | /** 19 | * Wraps an uvu test function so that it fails if the function doesn't complete 20 | * in the given amount of time. Uvu has no built-in timeout support (see 21 | * https://github.com/lukeed/uvu/issues/33). 22 | * 23 | * @param handler The uvu test function. 24 | * @param ms Millisecond failure timeout. 25 | */ 26 | const timeout = ( 27 | handler: uvu.Callback, 28 | ms = DEFAULT_UVU_TIMEOUT, 29 | ): uvu.Callback => { 30 | return (...args) => { 31 | let timerId: ReturnType; 32 | return Promise.race([ 33 | handler(...args), 34 | new Promise((_resolve, reject) => { 35 | timerId = setTimeout(() => { 36 | // Log that we timed out, helpful to see when looking through logs 37 | // when we started shutting down the rig because of a timeout, 38 | // because all logs after this point aren't part of the normal test. 39 | console.error('Test timed out.'); 40 | reject(new Error(`Test timed out after ${ms} milliseconds.`)); 41 | }, ms); 42 | }), 43 | ]).finally(() => { 44 | clearTimeout(timerId); 45 | }); 46 | }; 47 | }; 48 | 49 | export const rigTest = ( 50 | handler: uvu.Callback, 51 | inputOptions?: {flaky?: boolean; ms?: number}, 52 | ): uvu.Callback => { 53 | const {flaky, ms} = { 54 | flaky: false, 55 | ms: DEFAULT_UVU_TIMEOUT, 56 | ...inputOptions, 57 | }; 58 | const runTest: uvu.Callback = async (context) => { 59 | await using rig = await (async () => { 60 | if (context.rig !== undefined) { 61 | // if the suite provides a rig, use it, it's already been 62 | // configured for these tests specifically. 63 | // we'll dispose of it ourselves, but that's ok, disposing multiple 64 | // times is a noop 65 | return context.rig; 66 | } 67 | const rig = new WireitTestRig(); 68 | 69 | await rig.setup(); 70 | return rig; 71 | })(); 72 | try { 73 | await timeout(handler, ms)({...context, rig}); 74 | } catch (e) { 75 | const consoleCommandRed = '\x1b[31m'; 76 | const consoleReset = '\x1b[0m'; 77 | const consoleBold = '\x1b[1m'; 78 | console.log( 79 | `${consoleCommandRed}✘${consoleReset} Test failed: ${consoleBold}${context.__test__}${consoleReset}`, 80 | ); 81 | console.group(); 82 | await rig.reportFullLogs(); 83 | console.groupEnd(); 84 | throw e; 85 | } 86 | }; 87 | 88 | if (flaky) { 89 | return async (context) => { 90 | try { 91 | return await runTest(context); 92 | } catch { 93 | console.log('Test failed, retrying...'); 94 | } 95 | return await runTest(context); 96 | }; 97 | } 98 | return runTest; 99 | }; 100 | -------------------------------------------------------------------------------- /src/test/util/test-rig-command-child.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // This module is invoked whenever a WireitTestRigCommand child process is 8 | // spawned. 9 | 10 | import * as net from 'net'; 11 | import {unreachable} from '../../util/unreachable.js'; 12 | import { 13 | IpcClient, 14 | ChildToRigMessage, 15 | RigToChildMessage, 16 | } from './test-rig-command-interface.js'; 17 | 18 | class ChildIpcClient extends IpcClient { 19 | #sigintIntercepted = false; 20 | 21 | constructor(socket: net.Socket) { 22 | super(socket); 23 | process.on('SIGINT', () => { 24 | // Don't exit if the rig is going to call exit manually. 25 | if (!this.#sigintIntercepted) { 26 | this.#closeSocketAndExit(0); 27 | } 28 | }); 29 | } 30 | 31 | protected override _onMessage(message: RigToChildMessage): void { 32 | switch (message.type) { 33 | case 'exit': { 34 | this.#closeSocketAndExit(message.code); 35 | break; 36 | } 37 | case 'stdout': { 38 | process.stdout.write(message.str); 39 | break; 40 | } 41 | case 'stderr': { 42 | process.stderr.write(message.str); 43 | break; 44 | } 45 | case 'environmentRequest': { 46 | this._send({ 47 | type: 'environmentResponse', 48 | cwd: process.cwd(), 49 | argv: process.argv, 50 | env: process.env, 51 | }); 52 | break; 53 | } 54 | case 'interceptSigint': { 55 | this.#sigintIntercepted = true; 56 | process.on('SIGINT', () => { 57 | this._send({type: 'sigintReceived'}); 58 | }); 59 | break; 60 | } 61 | default: { 62 | console.error( 63 | `Unhandled message type ${ 64 | (unreachable(message) as RigToChildMessage).type 65 | }`, 66 | ); 67 | process.exit(1); 68 | break; 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * Gracefully close the socket before and exit. This helps avoid occasional 75 | * ECONNRESET errors on the other side. 76 | */ 77 | #closeSocketAndExit(code: number) { 78 | socket.end(() => { 79 | process.exit(code); 80 | }); 81 | } 82 | } 83 | 84 | const ipcPath = process.argv[2]; 85 | if (!ipcPath) { 86 | console.error('Error: expected first argument to be a socket/pipe filename.'); 87 | process.exit(1); 88 | } 89 | const socket = net.createConnection(ipcPath); 90 | new ChildIpcClient(socket); 91 | -------------------------------------------------------------------------------- /src/test/util/test-rig-command-interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as net from 'net'; 8 | import {Deferred} from '../../util/deferred.js'; 9 | 10 | /** 11 | * A message sent from the test rig to a spawned command. 12 | */ 13 | export type RigToChildMessage = 14 | | StdoutMessage 15 | | StderrMessage 16 | | ExitMessage 17 | | EnvironmentRequestMessage 18 | | InterceptSigintMessage; 19 | 20 | /** 21 | * Tell the command to emit the given string to its stdout stream. 22 | */ 23 | export interface StdoutMessage { 24 | type: 'stdout'; 25 | str: string; 26 | } 27 | 28 | /** 29 | * Tell the command to emit the given string to its stderr stream. 30 | */ 31 | export interface StderrMessage { 32 | type: 'stderr'; 33 | str: string; 34 | } 35 | 36 | /** 37 | * Tell the command to exit with the given code. 38 | */ 39 | export interface ExitMessage { 40 | type: 'exit'; 41 | code: number; 42 | } 43 | 44 | /** 45 | * The the command to wait until a SIGINT signal is received, and then send a 46 | * messsage back instead of exiting. 47 | */ 48 | export interface InterceptSigintMessage { 49 | type: 'interceptSigint'; 50 | } 51 | 52 | /** 53 | * Ask the command for information about its environment (argv, cwd, env). 54 | */ 55 | export interface EnvironmentRequestMessage { 56 | type: 'environmentRequest'; 57 | } 58 | 59 | /** 60 | * A message sent from a spawned command to the test rig. 61 | */ 62 | export type ChildToRigMessage = 63 | | EnvironmentResponseMessage 64 | | SigintReceivedMessage; 65 | 66 | /** 67 | * Report to the rig what cwd, argv, and environment variables were set when 68 | * Wireit spawned this command. 69 | */ 70 | export interface EnvironmentResponseMessage { 71 | type: 'environmentResponse'; 72 | cwd: string; 73 | argv: string[]; 74 | env: {[key: string]: string | undefined}; 75 | } 76 | 77 | /** 78 | * Report the rig that a SIGINT signal has been received. 79 | */ 80 | export interface SigintReceivedMessage { 81 | type: 'sigintReceived'; 82 | } 83 | 84 | /** 85 | * Indicates the end of a JSON message on an IPC data stream. This is the 86 | * "record separator" ASCII character. 87 | */ 88 | export const MESSAGE_END_MARKER = '\x1e'; 89 | 90 | /** 91 | * Sends and receives messages over an IPC data stream. 92 | */ 93 | export abstract class IpcClient { 94 | readonly #socket: net.Socket; 95 | protected readonly _closed = new Deferred(); 96 | #incomingDataBuffer = ''; 97 | 98 | constructor(socket: net.Socket) { 99 | this.#socket = socket; 100 | socket.on('data', this.#onData); 101 | socket.once('close', () => { 102 | this._closed.resolve(); 103 | socket.removeListener('data', this.#onData); 104 | }); 105 | } 106 | 107 | protected _send(message: Outgoing): void { 108 | if (this._closed.settled) { 109 | throw new Error('Connection is closed'); 110 | } 111 | this.#socket.write(JSON.stringify(message)); 112 | this.#socket.write(MESSAGE_END_MARKER); 113 | } 114 | 115 | protected abstract _onMessage(message: Incoming): void; 116 | 117 | /** 118 | * Handle an incoming message. 119 | * 120 | * Note that each data event could contain a partial message, or multiple 121 | * messages. The special MESSAGE_END_MARKER character is used to detect the end 122 | * of each complete JSON message in the stream. 123 | */ 124 | #onData = (data: Buffer) => { 125 | if (this._closed.settled) { 126 | throw new Error('Connection is closed'); 127 | } 128 | for (const char of data.toString()) { 129 | if (char === MESSAGE_END_MARKER) { 130 | const message = JSON.parse(this.#incomingDataBuffer) as Incoming; 131 | this.#incomingDataBuffer = ''; 132 | this._onMessage(message); 133 | } else { 134 | this.#incomingDataBuffer += char; 135 | } 136 | } 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /src/test/util/windows.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {IS_WINDOWS} from '../../util/windows.js'; 8 | 9 | /** 10 | * If we're on Windows, replace all forward-slashes with back-slashes. 11 | */ 12 | export const windowsifyPathIfOnWindows = (path: string) => 13 | IS_WINDOWS ? path.replace(/\//g, '\\') : path; 14 | -------------------------------------------------------------------------------- /src/util/ast.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as jsonParser from 'jsonc-parser'; 8 | import {parseTree as parseTreeInternal, ParseError} from 'jsonc-parser'; 9 | import {Result, Diagnostic} from '../error.js'; 10 | import {Failure} from '../event.js'; 11 | import * as pathlib from 'path'; 12 | export {ParseError} from 'jsonc-parser'; 13 | 14 | export type ValueTypes = string | number | boolean | null | undefined; 15 | 16 | export interface JsonFile { 17 | path: string; 18 | contents: string; 19 | } 20 | 21 | /** 22 | * A JSON AST node. 23 | * 24 | * A safer override, preferring unknown over any. 25 | */ 26 | export interface JsonAstNode 27 | extends Readonly { 28 | readonly value: T; 29 | readonly children?: JsonAstNode[]; 30 | readonly parent?: JsonAstNode; 31 | } 32 | 33 | /** 34 | * An extended JSON AST node for an array of values. 35 | * 36 | * We do this to avoid mutating the JsonAstNodes, which are produced by the 37 | * parser, and only have primitive values. 38 | */ 39 | export interface ArrayNode { 40 | readonly node: JsonAstNode; 41 | readonly values: T[]; 42 | } 43 | 44 | /** 45 | * A JSON value that is inside an object literal, and that has a reference 46 | * to its key in that object. 47 | */ 48 | export interface NamedAstNode 49 | extends JsonAstNode { 50 | /** 51 | * If `this` represents: 52 | * ```json 53 | * "key": "value", 54 | * ~~~~~~~ 55 | * ``` 56 | * 57 | * Then `this.name` represents: 58 | * ```json 59 | * "key": "value", 60 | * ~~~~~ 61 | * ``` 62 | */ 63 | name: JsonAstNode; 64 | } 65 | 66 | export function findNamedNodeAtLocation( 67 | astNode: JsonAstNode, 68 | path: jsonParser.JSONPath, 69 | file: JsonFile, 70 | ): Result { 71 | const node = findNodeAtLocation(astNode, path) as NamedAstNode | undefined; 72 | const parent = node?.parent; 73 | if (node === undefined || parent === undefined) { 74 | return {ok: true, value: undefined}; 75 | } 76 | const name = parent.children?.[0]; 77 | if (parent.type !== 'property' || name === undefined) { 78 | return { 79 | ok: false, 80 | error: { 81 | type: 'failure', 82 | reason: 'invalid-config-syntax', 83 | script: {packageDir: pathlib.dirname(file.path)}, 84 | diagnostic: { 85 | severity: 'error', 86 | message: `Expected a property, but got a ${parent.type}`, 87 | location: { 88 | file, 89 | range: {offset: astNode.offset, length: astNode.length}, 90 | }, 91 | }, 92 | }, 93 | }; 94 | } 95 | node.name = name; 96 | return {ok: true, value: node}; 97 | } 98 | 99 | export function findNodeAtLocation( 100 | astNode: JsonAstNode, 101 | path: jsonParser.JSONPath, 102 | ): JsonAstNode | undefined { 103 | return jsonParser.findNodeAtLocation(astNode, path) as 104 | | JsonAstNode 105 | | undefined; 106 | } 107 | 108 | export function parseTree( 109 | filePath: string, 110 | json: string, 111 | ): Result { 112 | const errors: ParseError[] = []; 113 | const result = parseTreeInternal(json, errors); 114 | if (errors.length > 0) { 115 | const diagnostics: Diagnostic[] = errors.map((error) => ({ 116 | severity: 'error', 117 | message: `JSON syntax error`, 118 | location: { 119 | file: { 120 | path: filePath, 121 | contents: json, 122 | ast: result as JsonAstNode, 123 | }, 124 | range: { 125 | offset: error.offset, 126 | length: error.length, 127 | }, 128 | }, 129 | })); 130 | return { 131 | ok: false, 132 | error: { 133 | type: 'failure', 134 | reason: 'invalid-json-syntax', 135 | script: {packageDir: pathlib.dirname(filePath)}, 136 | diagnostics, 137 | }, 138 | }; 139 | } 140 | return {ok: true, value: result as JsonAstNode}; 141 | } 142 | -------------------------------------------------------------------------------- /src/util/async-cache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * A cache for values that are asynchronously computed that ensures that we 9 | * will compute the value for each key at most once. 10 | */ 11 | export class AsyncCache { 12 | readonly #cache = new Map>(); 13 | 14 | async getOrCompute(key: K, compute: () => Promise): Promise { 15 | let result = this.#cache.get(key); 16 | if (result === undefined) { 17 | result = compute(); 18 | this.#cache.set(key, result); 19 | } 20 | return result; 21 | } 22 | 23 | get values() { 24 | return this.#cache.values(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/util/copy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from './fs.js'; 8 | import * as pathlib from 'path'; 9 | import {optimizeMkdirs} from './optimize-mkdirs.js'; 10 | import {IS_WINDOWS} from '../util/windows.js'; 11 | 12 | import type {AbsoluteEntry} from './glob.js'; 13 | 14 | /** 15 | * Copy all of the given files and directories from one directory to another. 16 | * 17 | * Directories are NOT copied recursively. If a directory is listed in 18 | * {@link entries} without any of its children being listed, then an empty 19 | * directory will be created. 20 | * 21 | * Parent directories are created automatically. E.g. listing "foo/bar" will 22 | * automatically create "foo/", even if "foo/" wasn't listed. 23 | */ 24 | export const copyEntries = async ( 25 | entries: AbsoluteEntry[], 26 | sourceDir: string, 27 | destDir: string, 28 | ): Promise => { 29 | if (entries.length === 0) { 30 | return; 31 | } 32 | 33 | const files = new Set(); 34 | const symlinks = new Set(); 35 | const directories = new Set(); 36 | for (const {path: absolutePath, dirent} of entries) { 37 | const relativePath = pathlib.relative(sourceDir, absolutePath); 38 | if (dirent.isDirectory()) { 39 | directories.add(pathlib.join(destDir, relativePath)); 40 | } else { 41 | directories.add(pathlib.join(destDir, pathlib.dirname(relativePath))); 42 | if (dirent.isSymbolicLink()) { 43 | symlinks.add(relativePath); 44 | } else { 45 | files.add(relativePath); 46 | } 47 | } 48 | } 49 | 50 | await Promise.all( 51 | optimizeMkdirs([...directories]).map((path) => 52 | fs.mkdir(path, {recursive: true}), 53 | ), 54 | ); 55 | 56 | const copyPromises = []; 57 | for (const path of files) { 58 | copyPromises.push( 59 | copyFileGracefully( 60 | pathlib.join(sourceDir, path), 61 | pathlib.join(destDir, path), 62 | ), 63 | ); 64 | } 65 | for (const path of symlinks) { 66 | copyPromises.push( 67 | copySymlinkGracefully( 68 | pathlib.join(sourceDir, path), 69 | pathlib.join(destDir, path), 70 | ), 71 | ); 72 | } 73 | await Promise.all(copyPromises); 74 | }; 75 | 76 | /** 77 | * Copy a file. If the source doesn't exist, do nothing. If the destination 78 | * already exists, throw an error. 79 | */ 80 | const copyFileGracefully = async (src: string, dest: string): Promise => { 81 | try { 82 | await fs.copyFile( 83 | src, 84 | dest, 85 | // COPYFILE_FICLONE: Copy the file using copy-on-write semantics, so that 86 | // the copy takes constant time and space. This is a noop currently 87 | // on some platforms, but it's a nice optimization to have. 88 | // See https://github.com/libuv/libuv/issues/2936 for macos support. 89 | fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE, 90 | ); 91 | } catch (error) { 92 | const {code} = error as {code: string}; 93 | if (code === /* does not exist */ 'ENOENT') { 94 | return; 95 | } 96 | throw error; 97 | } 98 | }; 99 | 100 | /** 101 | * Copy a symlink verbatim without following or resolving the target. If the 102 | * source doesn't exist, do nothing. 103 | */ 104 | const copySymlinkGracefully = async ( 105 | src: string, 106 | dest: string, 107 | ): Promise => { 108 | try { 109 | const target = await fs.readlink(src, {encoding: 'buffer'}); 110 | // Windows symlinks need to be flagged for whether the target is a file or a 111 | // directory. We can't derive that from the symlink itself, so we instead 112 | // need to check the type of the target. 113 | const windowsType = IS_WINDOWS 114 | ? // The target could be in the source or the destination, check both. 115 | ((await detectWindowsSymlinkType(target, src)) ?? 116 | (await detectWindowsSymlinkType(target, dest)) ?? 117 | // It doesn't exist in either place, so there's no way to know. Just 118 | // assume "file". 119 | 'file') 120 | : undefined; 121 | await fs.symlink(target, dest, windowsType); 122 | } catch (error) { 123 | const {code} = error as {code: string}; 124 | if (code === /* does not exist */ 'ENOENT') { 125 | return; 126 | } 127 | throw error; 128 | } 129 | }; 130 | 131 | /** 132 | * Resolve symlink {@link target} relative to {@link linkPath} and try to detect 133 | * whether the target is a file or directory. If the target doesn't exist, 134 | * returns undefined. 135 | */ 136 | const detectWindowsSymlinkType = async ( 137 | target: Buffer, 138 | linkPath: string, 139 | ): Promise<'file' | 'dir' | undefined> => { 140 | const resolved = pathlib.resolve( 141 | pathlib.dirname(linkPath), 142 | target.toString(), 143 | ); 144 | try { 145 | const stats = await fs.stat(resolved); 146 | return stats.isDirectory() ? 'dir' : 'file'; 147 | } catch (error) { 148 | const {code} = error as {code: string}; 149 | if (code === /* does not exist */ 'ENOENT') { 150 | return undefined; 151 | } 152 | throw error; 153 | } 154 | }; 155 | -------------------------------------------------------------------------------- /src/util/deferred.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * Convenience class for tracking a promise alongside its resolve and reject 9 | * functions. 10 | */ 11 | export class Deferred { 12 | readonly promise: Promise; 13 | readonly #resolve: (value: T) => void; 14 | readonly #reject: (reason: Error) => void; 15 | #settled = false; 16 | 17 | constructor() { 18 | let res: (value: T) => void, rej: (reason: Error) => void; 19 | this.promise = new Promise((resolve, reject) => { 20 | res = resolve; 21 | rej = reject; 22 | }); 23 | this.#resolve = res!; 24 | this.#reject = rej!; 25 | } 26 | 27 | get settled() { 28 | return this.#settled; 29 | } 30 | 31 | resolve(value: T): void { 32 | this.#settled = true; 33 | this.#resolve(value); 34 | } 35 | 36 | reject(reason: Error): void { 37 | this.#settled = true; 38 | this.#reject(reason); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/util/delete.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from './fs.js'; 8 | import * as pathlib from 'path'; 9 | 10 | import type {AbsoluteEntry} from './glob.js'; 11 | 12 | /** 13 | * Delete all of the given files and directories. 14 | * 15 | * Directories are NOT deleted recursively. To delete a directory, it must 16 | * either already be empty, or all of its transitive children must be explicitly 17 | * provided in the {@link entries} parameter. Deletes are performed depth-first 18 | * so that children are deleted before their parents. If a directory is still 19 | * not empty after all of the provided children have been deleted, then it is 20 | * left in-place. 21 | */ 22 | export const deleteEntries = async ( 23 | entries: AbsoluteEntry[], 24 | ): Promise => { 25 | if (entries.length === 0) { 26 | return; 27 | } 28 | 29 | const directories = []; 30 | const unlinkPromises = []; 31 | for (const {path, dirent} of entries) { 32 | if (dirent.isDirectory()) { 33 | // Don't delete directories yet. 34 | directories.push(path); 35 | } else { 36 | // Files can start deleting immediately. 37 | unlinkPromises.push(unlinkGracefully(path)); 38 | } 39 | } 40 | 41 | // Wait for all files to be deleted before we start deleting directories, 42 | // because directories need to be empty to be deleted. 43 | await Promise.all(unlinkPromises); 44 | 45 | if (directories.length === 0) { 46 | return; 47 | } 48 | if (directories.length === 1) { 49 | // Minor optimization for the common case of 1 directory. We've already 50 | // deleted all regular files, and we don't delete directories recursively. 51 | // So either [1] this directory is empty and we should delete it, or [2] it 52 | // has a child directory that was not explicitly listed so we should leave 53 | // it in-place. 54 | await rmdirGracefully(directories[0]!); 55 | return; 56 | } 57 | 58 | // We have multiple directories to delete. We must delete child directories 59 | // before their parents, because directories need to be empty to be deleted. 60 | // 61 | // Sorting from longest to shortest path and deleting in serial is a simple 62 | // solution, but we prefer to go in parallel. 63 | // 64 | // Build a tree from the path hierarchy, then delete depth-first. 65 | const root: Directory = {children: {}}; 66 | for (const path of directories) { 67 | let cur = root; 68 | for (const part of path.split(pathlib.sep)) { 69 | cur = cur.children[part] ??= {children: {}}; 70 | } 71 | cur.pathIfShouldDelete = path; 72 | } 73 | await deleteDirectoriesDepthFirst(root); 74 | }; 75 | 76 | interface Directory { 77 | /** If this directory should be deleted, its full path. */ 78 | pathIfShouldDelete?: string; 79 | /** Child directories that need to be deleted first. */ 80 | children: {[dir: string]: Directory}; 81 | } 82 | 83 | /** 84 | * Walk a {@link Directory} tree depth-first, deleting any directories that were 85 | * scheduled for deletion as long as they are empty. 86 | */ 87 | const deleteDirectoriesDepthFirst = async ( 88 | directory: Directory, 89 | ): Promise => { 90 | const childrenDeleted = await Promise.all( 91 | Object.values(directory.children).map((child) => 92 | deleteDirectoriesDepthFirst(child), 93 | ), 94 | ); 95 | if (directory.pathIfShouldDelete === undefined) { 96 | // This directory wasn't scheduled for deletion. 97 | return false; 98 | } 99 | if (childrenDeleted.some((deleted) => !deleted)) { 100 | // A child directory wasn't deleted, so there's no point trying to delete 101 | // this directory, because we know we're not empty and would fail. 102 | return false; 103 | } 104 | return rmdirGracefully(directory.pathIfShouldDelete); 105 | }; 106 | 107 | /** 108 | * Delete a file. If it doesn't exist, do nothing. 109 | */ 110 | const unlinkGracefully = async (path: string): Promise => { 111 | try { 112 | await fs.unlink(path); 113 | } catch (error) { 114 | const {code} = error as {code: string}; 115 | if (code === /* does not exist */ 'ENOENT') { 116 | return; 117 | } 118 | throw error; 119 | } 120 | }; 121 | 122 | /** 123 | * Delete a directory. If it doesn't exist or isn't empty, do nothing. 124 | * 125 | * @returns True if the directory was deleted or already didn't exist. False 126 | * otherwise. 127 | */ 128 | const rmdirGracefully = async (path: string): Promise => { 129 | try { 130 | await fs.rmdir(path); 131 | } catch (error) { 132 | const {code} = error as {code: string}; 133 | if (code === /* does not exist */ 'ENOENT') { 134 | return true; 135 | } 136 | if (code === /* not empty */ 'ENOTEMPTY') { 137 | return false; 138 | } 139 | throw error; 140 | } 141 | return true; 142 | }; 143 | -------------------------------------------------------------------------------- /src/util/dispose.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2023 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // Quick dispose polyfills. 8 | type Writeable = {-readonly [P in keyof T]: T[P]}; 9 | 10 | if (!Symbol.dispose) { 11 | (Symbol as Writeable).dispose = Symbol( 12 | 'dispose', 13 | ) as typeof Symbol.dispose; 14 | } 15 | if (!Symbol.asyncDispose) { 16 | (Symbol as Writeable).asyncDispose = Symbol( 17 | 'asyncDispose', 18 | ) as typeof Symbol.asyncDispose; 19 | } 20 | 21 | if (!Symbol.asyncDispose) { 22 | type Writeable = {-readonly [P in keyof T]: T[P]}; 23 | (Symbol as Writeable).asyncDispose = Symbol( 24 | 'asyncDispose', 25 | ) as typeof Symbol.asyncDispose; 26 | } 27 | 28 | export {}; 29 | -------------------------------------------------------------------------------- /src/util/line-monitor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Deferred} from './deferred.js'; 8 | 9 | import type {ScriptChildProcess} from '../script-child-process.js'; 10 | import type {Result} from '../error.js'; 11 | 12 | /** 13 | * Monitors the stdout and stderr of a child process line-by-line searching for 14 | * a match of the given regular expression. 15 | * 16 | * Note we can't use readline here because we want to check lines that haven't 17 | * completed yet. 18 | */ 19 | export class LineMonitor { 20 | readonly #child: ScriptChildProcess; 21 | readonly #pattern: RegExp; 22 | readonly #matched = new Deferred>(); 23 | #stdout = ''; 24 | #stderr = ''; 25 | 26 | /** 27 | * Resolves to `{"ok": true}` when a match was found or `{"ok": false}` when 28 | * this monitor was aborted. 29 | */ 30 | readonly matched = this.#matched.promise; 31 | 32 | constructor(child: ScriptChildProcess, pattern: RegExp) { 33 | this.#child = child; 34 | this.#pattern = pattern; 35 | child.stdout.on('data', this.#onStdout); 36 | child.stderr.on('data', this.#onStderr); 37 | } 38 | 39 | abort() { 40 | this.#removeEventListeners(); 41 | this.#matched.resolve({ok: false, error: undefined}); 42 | } 43 | 44 | #removeEventListeners() { 45 | this.#child.stdout.removeListener('data', this.#onStdout); 46 | this.#child.stderr.removeListener('data', this.#onStderr); 47 | } 48 | 49 | #onStdout = (data: string | Buffer) => { 50 | this.#stdout = this.#check(this.#stdout + String(data)); 51 | }; 52 | 53 | #onStderr = (data: string | Buffer) => { 54 | this.#stderr = this.#check(this.#stderr + String(data)); 55 | }; 56 | 57 | #check(buffer: string): string { 58 | const lines = buffer.split(/\n/g); 59 | let end = 0; 60 | for (let i = 0; i < lines.length; i++) { 61 | const line = lines[i]!; 62 | if (i !== lines.length - 1) { 63 | // Don't move beyond the final line, since it might be incomplete, and 64 | // we want to match the entire line the next time _check is called. 65 | end += line.length + 1; 66 | } 67 | if (this.#pattern.test(line)) { 68 | this.#removeEventListeners(); 69 | this.#matched.resolve({ok: true, value: undefined}); 70 | break; 71 | } 72 | } 73 | return buffer.slice(end); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/util/manifest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import type {Stats} from 'fs'; 8 | 9 | /** 10 | * Metadata about a file which we use as a heuristic to decide whether two files 11 | * are equal without needing to read its contents. 12 | */ 13 | export interface FileManifestEntry { 14 | /** File type */ 15 | t: 16 | | /** File */ 'f' 17 | | /** Directory */ 'd' 18 | | /** Symbolic link */ 'l' 19 | | /** Block device */ 'b' 20 | | /** Character device */ 'c' 21 | | /** FIFO pipe */ 'p' 22 | | /** Socket */ 's' 23 | | /** Unknown */ '?'; 24 | /** Last content modification time. `undefined` for directories. */ 25 | m: number | undefined; 26 | /** Size in bytes. `undefined` for directories. */ 27 | s: number | undefined; 28 | } 29 | 30 | /** 31 | * A JSON-serialized manifest of files. 32 | */ 33 | export type FileManifestString = string & { 34 | __FileManifestStringBrand__: never; 35 | }; 36 | 37 | export function computeManifestEntry(stats: Stats): FileManifestEntry { 38 | return { 39 | t: stats.isFile() 40 | ? 'f' 41 | : stats.isDirectory() 42 | ? 'd' 43 | : stats.isSymbolicLink() 44 | ? 'l' 45 | : stats.isBlockDevice() 46 | ? 'b' 47 | : stats.isCharacterDevice() 48 | ? 'c' 49 | : stats.isFIFO() 50 | ? 'p' 51 | : stats.isSocket() 52 | ? 's' 53 | : '?', 54 | // Don't include timestamp or size for directories, because they can change 55 | // when a child is added or removed. If we are tracking the child, then it 56 | // will have its own entry. If we are not tracking the child, then we don't 57 | // want it to affect the manifest. 58 | m: stats.isDirectory() ? undefined : stats.mtimeMs, 59 | s: stats.isDirectory() ? undefined : stats.size, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/util/optimize-mkdirs.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {dirname} from 'path'; 8 | 9 | /** 10 | * Given a set of filesystem directory paths, returns the smallest set of 11 | * recursive {@link fs.mkdir} operations required to create all directories. 12 | * 13 | * For example, given: 14 | * 15 | * a/b/c 16 | * a/b 17 | * d 18 | * d/e/f/g/h 19 | * 20 | * Returns: 21 | * 22 | * a/b/c 23 | * d/e/f/g/h 24 | * 25 | * Note this function does an in-place sort of the given dirs. 26 | */ 27 | export const optimizeMkdirs = (dirs: string[]): string[] => { 28 | if (dirs.length <= 1) { 29 | return dirs; 30 | } 31 | const ops = []; 32 | // Sorting from longest to shortest ensures that child directories come before 33 | // parents (e.g. [d/e, d/e/f, a, a/b/c] => [d/e/f, a/b/c, d/e, a]). 34 | // Parent/child adjacency doesn't matter. 35 | dirs.sort((a, b) => b.length - a.length); 36 | const handled = new Set(); 37 | for (const dir of dirs) { 38 | if (handled.has(dir)) { 39 | // Skip this directory because it has already been handled by a longer 40 | // path we've already seen (e.g. "a/b/c" also creates "a/b" and "a"). 41 | continue; 42 | } 43 | ops.push(dir); 44 | // Add this directory and all of its parent directories to the "done" set. 45 | let cur = dir; 46 | while (true) { 47 | handled.add(cur); 48 | const parent = dirname(cur); 49 | if (parent === cur) { 50 | break; 51 | } 52 | cur = parent; 53 | } 54 | } 55 | return ops; 56 | }; 57 | -------------------------------------------------------------------------------- /src/util/package-json-reader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Result} from '../error.js'; 8 | import {AsyncCache} from './async-cache.js'; 9 | import {PackageJson} from './package-json.js'; 10 | import * as pathlib from 'path'; 11 | import * as fs from './fs.js'; 12 | import {parseTree} from './ast.js'; 13 | 14 | export const astKey = Symbol('ast'); 15 | 16 | export interface FileSystem { 17 | readFile(path: string, options: 'utf8'): Promise; 18 | } 19 | 20 | /** 21 | * Reads package.json files and caches them. 22 | */ 23 | export class CachingPackageJsonReader { 24 | readonly #cache = new AsyncCache>(); 25 | readonly #fs; 26 | constructor(filesystem: FileSystem = fs) { 27 | this.#fs = filesystem; 28 | } 29 | 30 | async read(packageDir: string): Promise> { 31 | return this.#cache.getOrCompute(packageDir, async () => { 32 | const path = pathlib.resolve(packageDir, 'package.json'); 33 | let contents; 34 | try { 35 | contents = await this.#fs.readFile(path, 'utf8'); 36 | } catch (error) { 37 | if ((error as {code?: string}).code === 'ENOENT') { 38 | return { 39 | ok: false, 40 | error: { 41 | type: 'failure', 42 | reason: 'missing-package-json', 43 | script: {packageDir}, 44 | }, 45 | }; 46 | } 47 | throw error; 48 | } 49 | const astResult = parseTree(path, contents); 50 | if (!astResult.ok) { 51 | return astResult; 52 | } 53 | const packageJsonFile = new PackageJson( 54 | {contents, path}, 55 | astResult.value, 56 | ); 57 | return {ok: true, value: packageJsonFile}; 58 | }); 59 | } 60 | 61 | async *getFailures() { 62 | const values = await Promise.all([...this.#cache.values]); 63 | for (const result of values) { 64 | if (!result.ok) { 65 | yield result.error; 66 | continue; 67 | } 68 | const packageJson = result.value; 69 | yield* packageJson.failures; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/util/script-data-dir.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as pathlib from 'path'; 8 | 9 | import type {ScriptReference} from '../config.js'; 10 | 11 | /** 12 | * Get the directory name where Wireit data can be saved for a script. 13 | */ 14 | export const getScriptDataDir = (script: ScriptReference) => 15 | pathlib.join( 16 | script.packageDir, 17 | '.wireit', 18 | // Script names can contain any character, so they aren't safe to use 19 | // directly in a filepath, because certain characters aren't allowed on 20 | // certain filesystems (e.g. ":" is forbidden on Windows). Hex-encode 21 | // instead so that we only get safe ASCII characters. 22 | // 23 | // Reference: 24 | // https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions 25 | Buffer.from(script.name).toString('hex'), 26 | ); 27 | -------------------------------------------------------------------------------- /src/util/shuffle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * Randomize the order of an array in-place. 9 | */ 10 | export const shuffle = (array: Array): void => { 11 | // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm 12 | for (let i = array.length - 1; i > 0; i--) { 13 | const j = Math.floor(Math.random() * (i + 1)); 14 | [array[i], array[j]] = [array[j], array[i]]; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/util/unreachable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * TypeScript will error if it believes this function could be invoked. Useful 9 | * to check for branches that should never be reached (e.g. that all possible 10 | * cases in a switch are handled). 11 | */ 12 | export const unreachable = (value: never) => value; 13 | -------------------------------------------------------------------------------- /src/util/windows.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | /** 8 | * Whether we're running on Windows. 9 | */ 10 | export const IS_WINDOWS = process.platform === 'win32'; 11 | 12 | /** 13 | * If we're on Windows, convert all back-slashes to forward-slashes (e.g. 14 | * "foo\bar" -> "foo/bar"). 15 | */ 16 | export const posixifyPathIfOnWindows = (path: string) => 17 | IS_WINDOWS ? path.replace(/\\/g, '/') : path; 18 | 19 | /** 20 | * Overlay the given environment variables on top of the current process's 21 | * environment variables in a way that is reliable on Windows. 22 | * 23 | * Windows environment variable names are **sort of** case-insensitive. When you 24 | * `spawn` a process and pass 2 environment variables that differ only in case, 25 | * then the value that actually gets set is ambiguous, and depends on the Node 26 | * version. In Node 14 it seems to be the last one in iteration order, in Node 27 | * 16 it seems to be the first one after sorting. 28 | * 29 | * For example, if you run: 30 | * 31 | * ```ts 32 | * spawn('foo', { 33 | * env: { 34 | * PATH: 'C:\\extra;C:\\before', 35 | * Path: 'C:\\before' 36 | * } 37 | * }); 38 | * ``` 39 | * 40 | * Then sometimes the value that the spawned process receives could be 41 | * `C:\before`, and other times it could be `C:\extra;C:\before`. 42 | * 43 | * This function ensures that the values given in `augmentations` will always 44 | * win, by normalizing casing to match the casing that was already set in 45 | * `process.env`. 46 | */ 47 | export const augmentProcessEnvSafelyIfOnWindows = ( 48 | augmentations: Record, 49 | ): Record => { 50 | if (ENVIRONMENT_VARIABLE_CASINGS_IF_WINDOWS === undefined) { 51 | // On Linux and macOS, environment variables are case-sensitive, so there's 52 | // nothing special to do here. 53 | return {...process.env, ...augmentations}; 54 | } 55 | const augmented = {...process.env}; 56 | for (const [name, value] of Object.entries(augmentations)) { 57 | const existingNames = ENVIRONMENT_VARIABLE_CASINGS_IF_WINDOWS.get( 58 | name.toLowerCase(), 59 | ); 60 | if (existingNames === undefined) { 61 | augmented[name] = value; 62 | } else { 63 | for (const existingName of existingNames) { 64 | augmented[existingName] = value; 65 | } 66 | } 67 | } 68 | return augmented; 69 | }; 70 | 71 | /** 72 | * A map from lowercase environment variable name to the specific name casing(s) 73 | * that were found in this process's environment variables. 74 | * 75 | * This is an array because in Node 14 the `process.env` object can actually 76 | * contain multiple entries for the same variable with different casings, even 77 | * though the values are always the same. In Node 16, there is only one name 78 | * casing, even if it was spawned with multiple. 79 | */ 80 | const ENVIRONMENT_VARIABLE_CASINGS_IF_WINDOWS = IS_WINDOWS 81 | ? (() => { 82 | const map = new Map(); 83 | for (const caseSensitiveName of Object.keys(process.env)) { 84 | const lowerCaseName = caseSensitiveName.toLowerCase(); 85 | let arr = map.get(lowerCaseName); 86 | if (arr === undefined) { 87 | arr = []; 88 | map.set(lowerCaseName, arr); 89 | } 90 | arr.push(caseSensitiveName); 91 | } 92 | return map; 93 | })() 94 | : undefined; 95 | -------------------------------------------------------------------------------- /src/util/worker-pool.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {Deferred} from './deferred.js'; 8 | 9 | /** 10 | * A mechanism for ensuring that at most N tasks are taking place at once. 11 | * 12 | * Useful in Wireit to prevent running too many scripts at once and swamping 13 | * the system. For unlimited parallelism, just set numWorkers to Infinity. 14 | * 15 | * Note that node is still single threaded by default. This is useful for 16 | * Wireit because almost all work is happening in script commands which run 17 | * in separate processes. 18 | * 19 | * No guarantee is made about ordering or fairness of scheduling, though 20 | * as implemented it's currently LIFO. Deadlocks may occur if there are 21 | * dependencies between tasks. 22 | */ 23 | export class WorkerPool { 24 | #availableWorkers: number; 25 | readonly #waitingWorkers: Deferred[] = []; 26 | 27 | constructor(numWorkers: number) { 28 | if (numWorkers <= 0) { 29 | throw new Error( 30 | `WorkerPool needs a positive number of workers, got ${numWorkers}`, 31 | ); 32 | } 33 | this.#availableWorkers = numWorkers; 34 | } 35 | 36 | /** 37 | * Calls workFn and returns its result. 38 | * 39 | * However, no more than `numWorkers` simultaneous calls to workFns will 40 | * be running at any given time, to prevent overloading the machine. 41 | */ 42 | async run(workFn: () => Promise): Promise { 43 | if (this.#availableWorkers <= 0) { 44 | const waiter = new Deferred(); 45 | this.#waitingWorkers.push(waiter); 46 | await waiter.promise; 47 | if (this.#availableWorkers <= 0) { 48 | throw new Error( 49 | `Internal error: expected availableWorkers to be positive after task was awoken, but was ${this.#availableWorkers}`, 50 | ); 51 | } 52 | } 53 | this.#availableWorkers--; 54 | try { 55 | return await workFn(); 56 | } finally { 57 | this.#availableWorkers++; 58 | if (this.#availableWorkers <= 0) { 59 | // We intend to override any return or throw with this error in this 60 | // case. 61 | // eslint-disable-next-line no-unsafe-finally 62 | throw new Error( 63 | `Internal error: expected availableWorkers to be positive after incrementing, but was ${this.#availableWorkers}`, 64 | ); 65 | } 66 | this.#waitingWorkers.pop()?.resolve(); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "es2022", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "useDefineForClassFields": false, 8 | "lib": ["es2022", "esnext.disposable"], 9 | "rootDir": "src", 10 | "outDir": "lib", 11 | "strict": true, 12 | "sourceMap": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "allowSyntheticDefaultImports": true, 17 | "useUnknownInCatchVariables": true, 18 | "noImplicitOverride": true, 19 | "incremental": true, 20 | "tsBuildInfoFile": ".tsbuildinfo", 21 | "composite": true, 22 | "skipLibCheck": true 23 | }, 24 | "include": ["src/**/*.ts"], 25 | "exclude": [] 26 | } 27 | -------------------------------------------------------------------------------- /vscode-extension/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "wireit" extension will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | 8 | 9 | ## [0.8.0] - 2023-10-12 10 | 11 | - Add intellisense completions for dependencies. 12 | 13 | - Support the find all references command (default keybinding F12), to find all 14 | scripts that depend on the script under the cursor. This searches all 15 | package.json files reachable from all open package.json files, as well as from all package.json files in workspace roots. 16 | 17 | ## [0.7.0] - 2023-09-12 18 | 19 | - More reliably handle and report diagnostics for scripts with invalid 20 | configurations. Specifically fixed https://github.com/google/wireit/issues/803. 21 | 22 | ## [0.6.0] - 2023-02-06 23 | 24 | - Updated to allow scripts that are in the "wireit" section but not the main 25 | "scripts" section to be used as dependencies, added in Wireit v0.9.4. 26 | 27 | ## [0.5.0] - 2022-12-15 28 | 29 | - Updated to support the new "env" features of Wireit v0.9.1. 30 | 31 | ## [0.4.0] - 2022-11-14 32 | 33 | - Updated to support the new "service" and "cascade" features of Wireit v0.7.3. 34 | 35 | ## [0.3.0] - 2022-05-11 36 | 37 | - Use the same logic as the CLI for finding diagnostics. This adds many new 38 | diagnostics, like diagnostics for missing dependencies, or cycles in the 39 | dependency graph! 40 | 41 | - Add jump to definition support to jump right to where a dependency is defined. 42 | 43 | - Also added jump to definition for going from the scripts section to a wireit 44 | configuration object. 45 | 46 | ## [0.2.0] - 2022-05-04 47 | 48 | - Add code actions to fix some common mistakes, as well as to convert a script 49 | to use wireit. 50 | 51 | ## [0.1.0] - 2022-04-28 52 | 53 | - Applies a hardcoded JSON Schema to package.json files with types and 54 | documentation for the wireit config format. 55 | 56 | - Surfaces diagnostics from both the JSON schema and some static analysis. 57 | -------------------------------------------------------------------------------- /vscode-extension/HACKING.md: -------------------------------------------------------------------------------- 1 | ## Developing 2 | 3 | Run `npm run install-extension` to build and install the extension in your local VSCode. If you run `npm run install-extension --watch` it will rebuild and reinstall on every change, though you'll need to run `Developer: Reload Window` in VSCode to pick up on the changes. 4 | 5 | ## Publishing 6 | 7 | ### Getting access 8 | 9 | You'll need to do this once ever. 10 | 11 | 1. Visit [go/vscode-publishing](http://go/vscode-publishing) and follow the 12 | instructions on how to create an `@google.com` Microsoft account and file a 13 | ticket to be given access to the Google Azure DevOps group. 14 | 15 | 2. Ask another Wireit team-member to add you to the `polymer-vscode` Azure DevOps group. 16 | Wait for an email, and click the "Join" button. 17 | 18 | ### Creating a personal access token (PAT) 19 | 20 | You'll need to do this occasionally, depending the expiration date you set, and 21 | whether you still have access to the PAT. 22 | 23 | 1. Visit https://dev.azure.com/polymer-vscode/_usersSettings/tokens 24 | 2. Click "New Token" 25 | 3. Set Name to e.g. "Publish Wireit VSCode extension" 26 | 4. Set Organization to "polymer-vscode" 27 | 5. Set Expiration to "Custom defined" and set the date for e.g. 1 year 28 | 6. Set Scopes to "Custom defined" 29 | 7. Click "Show all scopes" 30 | 8. Scroll to "Marketplace" and check "Mangage" 31 | 9. Click "Create" 32 | 10. Copy the token and save it somewhere secure 33 | 34 | ### Publishing 35 | 36 | You'll need to do this every time you publish the extension. 37 | 38 | 1. `cd wireit/vscode-extension` 39 | 2. Edit `built/package.json` to increment the version number according to semver 40 | 3. Edit `CHANGELOG.md` to document the changes in the release 41 | 4. `npm run package` to build the extension 42 | 5. `npm run install-extension` to install a local copy of the extension. Reload 43 | VSCode, and do a bit of manual testing to be sure that everything is working 44 | (we have automated testing but it's worth double checking little fit and 45 | finish details). 46 | 6. Send a PR with the above changes, get it reviewed, and merge to `main`. 47 | 7. `npx vsce login google` 48 | 8. Enter your [PAT](#creating-a-personal-access-token-pat) 49 | 9. `npx vsce publish -i built/wireit.vsix` 50 | -------------------------------------------------------------------------------- /vscode-extension/README.md: -------------------------------------------------------------------------------- 1 | # The wireit VSCode extension 2 | 3 | The wireit VSCode extension provides editing assistance for package.json files that use [the wireit script runner](https://github.com/google/wireit). 4 | 5 | ## Features 6 | 7 | - ✍️ Autocompletion of properties in your wireit config 8 | - 📚 Documentation on hover for wireit properties 9 | - 🖍 Instant diagnostics, highlighting common mistakes 10 | - 🔗 Jump to definition 11 | - Jump straight to the definition of a dependency 12 | - Jump from a script to its wireit config 13 | - 👷 Code actions 14 | - Refactor a vanilla npm script to use wireit 15 | - Suggested fixes for a number of common errors 16 | -------------------------------------------------------------------------------- /vscode-extension/built/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/wireit/4f1ed7d494e4ffda0139cf72bee7fb7dd6c4dd2a/vscode-extension/built/logo.png -------------------------------------------------------------------------------- /vscode-extension/built/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wireit", 3 | "displayName": "wireit", 4 | "publisher": "google", 5 | "description": "For the wireit script runner", 6 | "version": "0.8.0", 7 | "contributes": { 8 | "commands": [], 9 | "jsonValidation": [ 10 | { 11 | "fileMatch": "package.json", 12 | "url": "./schema.json" 13 | } 14 | ] 15 | }, 16 | "main": "./client.js", 17 | "activationEvents": [ 18 | "onLanguage:json" 19 | ], 20 | "icon": "logo.png", 21 | "engines": { 22 | "vscode": "^1.66.0" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/google/wireit.git" 27 | }, 28 | "author": "Google LLC", 29 | "license": "Apache-2.0", 30 | "bugs": { 31 | "url": "https://github.com/google/wireit/issues" 32 | }, 33 | "homepage": "https://github.com/google/wireit#readme", 34 | "__metadata": { 35 | "id": "deb70ac0-40ad-4348-afd7-67f64daf4ef5", 36 | "publisherDisplayName": "Google", 37 | "publisherId": "93a45bde-b507-401c-9deb-7a098ebcded8", 38 | "isPreReleaseVersion": false 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /vscode-extension/esbuild.script.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as esbuild from 'esbuild'; 8 | 9 | await esbuild.build({ 10 | entryPoints: ['./src/client.ts'], 11 | bundle: true, 12 | outfile: 'built/client.js', 13 | platform: 'node', 14 | minify: true, 15 | target: 'es2018', 16 | format: 'cjs', 17 | color: true, 18 | external: ['vscode'], 19 | mainFields: ['module', 'main'], 20 | }); 21 | 22 | await esbuild.build({ 23 | entryPoints: ['../src/language-server.ts'], 24 | bundle: true, 25 | outfile: 'built/server.js', 26 | platform: 'node', 27 | minify: true, 28 | target: 'es2018', 29 | format: 'cjs', 30 | color: true, 31 | mainFields: ['module', 'main'], 32 | }); 33 | -------------------------------------------------------------------------------- /vscode-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wireit/internal-vscode-extension", 3 | "#comment": [ 4 | "This file is for dependency management and scripts, the file that", 5 | "is used for the actual vscode plugin is package-for-extension.json" 6 | ], 7 | "private": true, 8 | "scripts": { 9 | "vscode:prepublish": "npm run build:package", 10 | "package": "wireit", 11 | "test": "wireit", 12 | "test:actual": "wireit", 13 | "build": "wireit", 14 | "build:ts": "wireit", 15 | "build:copy-schema": "wireit", 16 | "build:bundle": "wireit", 17 | "watch": "npm run build watch", 18 | "install-extension": "wireit", 19 | "build:package": "wireit", 20 | "test:compile": "wireit" 21 | }, 22 | "engines": { 23 | "vscode": "^1.66.0" 24 | }, 25 | "wireit": { 26 | "build": { 27 | "dependencies": [ 28 | "build:ts", 29 | "build:package" 30 | ] 31 | }, 32 | "build:copy-schema": { 33 | "files": [ 34 | "../schema.json" 35 | ], 36 | "output": [ 37 | "schema.json" 38 | ], 39 | "command": "cp ../schema.json ./schema.json" 40 | }, 41 | "build:ts": { 42 | "dependencies": [ 43 | "..:build" 44 | ], 45 | "files": [ 46 | "tsconfig.json", 47 | "src/**/*.ts" 48 | ], 49 | "output": [ 50 | "lib" 51 | ], 52 | "command": "tsc --skipLibCheck || echo ''" 53 | }, 54 | "test:compile": { 55 | "dependencies": [ 56 | "..:build" 57 | ], 58 | "files": [ 59 | "tsconfig.json", 60 | "src/**/*.ts" 61 | ], 62 | "output": [], 63 | "command": "tsc --noEmit" 64 | }, 65 | "build:bundle": { 66 | "files": [ 67 | "src/**/*.ts", 68 | "../src/**/*.ts", 69 | "esbuild.script.mjs" 70 | ], 71 | "output": [ 72 | "built/client.js", 73 | "built/server.js" 74 | ], 75 | "command": "node esbuild.script.mjs" 76 | }, 77 | "test": { 78 | "dependencies": [ 79 | "test:compile", 80 | "test:actual" 81 | ] 82 | }, 83 | "test:actual": { 84 | "dependencies": [ 85 | "build:package", 86 | "build:ts" 87 | ], 88 | "files": [ 89 | "package.json", 90 | "built/" 91 | ], 92 | "output": [], 93 | "command": "node ./lib/test/scripts/runner.js" 94 | }, 95 | "build:package": { 96 | "dependencies": [ 97 | "build:bundle", 98 | "build:ts" 99 | ], 100 | "files": [ 101 | "../schema.json", 102 | "../LICENSE", 103 | "README.md", 104 | "CHANGELOG.md" 105 | ], 106 | "output": [ 107 | "built/schema.json", 108 | "built/LICENSE", 109 | "built/README.md", 110 | "built/CHANGELOG.md" 111 | ], 112 | "command": "node ./lib/scripts/copy-to-built.js" 113 | }, 114 | "package": { 115 | "dependencies": [ 116 | "build:package" 117 | ], 118 | "files": [ 119 | "built", 120 | "!built/*.vsix" 121 | ], 122 | "command": "cd built && vsce package -o wireit.vsix" 123 | }, 124 | "install-extension": { 125 | "dependencies": [ 126 | "package" 127 | ], 128 | "#comment": "Useful when manually testing, you can run this in watch mode, and do a > Developer: Reload Window in vscode to pick up the new version of the extension.", 129 | "command": "code --install-extension built/wireit.vsix" 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /vscode-extension/src/client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | // This is the client side of the language server. It runs inside of 8 | // VSCode directly, but doesn't do much work other than just communicating 9 | // to our language server, which does all the heavy lifting. 10 | 11 | import * as vscode from 'vscode'; 12 | import * as languageclient from 'vscode-languageclient/node'; 13 | 14 | let client: languageclient.LanguageClient | undefined; 15 | 16 | export async function activate(context: vscode.ExtensionContext) { 17 | const outputChannel = vscode.window.createOutputChannel('wireit'); 18 | const run: languageclient.NodeModule = { 19 | module: context.asAbsolutePath('server.js'), 20 | transport: languageclient.TransportKind.ipc, 21 | }; 22 | const debug: languageclient.NodeModule = { 23 | ...run, 24 | options: {execArgv: ['--nolazy', '--inspect=6009']}, 25 | }; 26 | 27 | client = new languageclient.LanguageClient( 28 | 'wireit', 29 | 'wireit server', 30 | {run, debug}, 31 | { 32 | documentSelector: [ 33 | // The server will only ever be notified about package.json files. 34 | { 35 | scheme: 'file', 36 | language: 'json', 37 | pattern: '**/package.json', 38 | }, 39 | ], 40 | // Uncomment for more debugging info: 41 | // traceOutputChannel: outputChannel, 42 | }, 43 | ); 44 | context.subscriptions.push( 45 | client.onNotification( 46 | 'window/logMessage', 47 | ({message}: {message: string}) => { 48 | // Log both to the output channel (when running in the IDE), and to 49 | // the console (when running as part of a test). 50 | outputChannel.appendLine(`languageServer: ${message}`); 51 | console.error(`languageServer: ${message}`); 52 | }, 53 | ), 54 | ); 55 | 56 | await client.start(); 57 | } 58 | 59 | export async function deactivate(): Promise { 60 | await client?.stop(); 61 | } 62 | -------------------------------------------------------------------------------- /vscode-extension/src/scripts/copy-to-built.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as fs from 'fs'; 8 | 9 | // We want to be deliberate about the files that are included in the extension. 10 | // This is also working around what looks like a bug in a combination of 11 | // npm workspaces, vsce, and npm starting at some version >8.12.2 and <=8.5.0 12 | // which causes issues with the current working directory when running scripts 13 | // in a workspace. 14 | // TODO(rictic): repro and file that bug 15 | 16 | fs.mkdirSync('built', {recursive: true}); 17 | fs.copyFileSync('../schema.json', './built/schema.json'); 18 | fs.copyFileSync('../LICENSE', './built/LICENSE'); 19 | fs.copyFileSync('./README.md', './built/README.md'); 20 | fs.copyFileSync('./CHANGELOG.md', './built/CHANGELOG.md'); 21 | -------------------------------------------------------------------------------- /vscode-extension/src/test/fixtures/incorrect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "build": "wireit" 4 | }, 5 | "wireit": { 6 | "build": { 7 | "command": 1 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /vscode-extension/src/test/fixtures/semantic_errors/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "not_wireit_command": "tsc", 4 | "neither_dependencies_nor_command": "wireit", 5 | "invalid_dependency": "wireit", 6 | "unrelated": "foo bar baz" 7 | }, 8 | "wireit": { 9 | "not_wireit_command": { 10 | "command": "tsc" 11 | }, 12 | "not_in_scripts": { 13 | "command": "foo" 14 | }, 15 | "invalid_dependency": { 16 | "command": "bar" 17 | }, 18 | "neither_dependencies_nor_command": { 19 | "clean": true 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /vscode-extension/src/test/main.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as vscode from 'vscode'; 8 | import * as pathlib from 'path'; 9 | 10 | import {test} from 'uvu'; 11 | import * as assert from 'uvu/assert'; 12 | 13 | test('the extension is installed', () => { 14 | const extensionIds = vscode.extensions.all.map((extension) => extension.id); 15 | const ourId = 'google.wireit'; 16 | assert.ok( 17 | extensionIds.includes(ourId), 18 | `Expected ${JSON.stringify(extensionIds)} to include '${ourId}'`, 19 | ); 20 | }); 21 | 22 | // Wait until the something is able to produce diagnostics, then return 23 | // those. 24 | async function getDiagnostics( 25 | doc: vscode.TextDocument, 26 | ): Promise { 27 | return await tryUntil(() => { 28 | const diagnostics = vscode.languages.getDiagnostics(doc.uri); 29 | if (diagnostics.length > 0) { 30 | return diagnostics; 31 | } 32 | }); 33 | } 34 | 35 | const TICKS_TO_WAIT = process.env.CI ? 1000 : 40; 36 | async function tryUntil( 37 | f: () => T | null | undefined | Promise, 38 | ): Promise { 39 | for (let i = 0; i < TICKS_TO_WAIT; i++) { 40 | const v = await f(); 41 | if (v != null) { 42 | return v; 43 | } 44 | // Is there a better way to wait for the server to be ready? 45 | await new Promise((resolve) => setTimeout(resolve, 100)); 46 | } 47 | throw new Error('tryUntil never got a value'); 48 | } 49 | 50 | // This is mainly a test that the schema is present and automatically 51 | // applies to all package.json files. The contents of the schema are 52 | // tested in the main wireit package. 53 | test('warns on a package.json based on the schema', async () => { 54 | const doc = await vscode.workspace.openTextDocument( 55 | vscode.Uri.file( 56 | pathlib.join(__dirname, '../../src/test/fixtures/incorrect/package.json'), 57 | ), 58 | ); 59 | await vscode.window.showTextDocument(doc); 60 | const diagnostic = await tryUntil(() => { 61 | return vscode.languages.getDiagnostics(doc.uri)?.find((d) => { 62 | if (`Incorrect type. Expected "string".` === d.message) { 63 | return d; 64 | } 65 | }); 66 | }); 67 | assert.equal(diagnostic.message, `Incorrect type. Expected "string".`); 68 | const range = diagnostic.range; 69 | assert.equal( 70 | { 71 | start: {line: range.start.line, character: range.start.character}, 72 | end: {line: range.end.line, character: range.end.character}, 73 | }, 74 | { 75 | start: {line: 6, character: 17}, 76 | end: {line: 6, character: 18}, 77 | }, 78 | JSON.stringify(range), 79 | ); 80 | }); 81 | 82 | test('warns on a package.json based on semantic analysis in the language server', async () => { 83 | const doc = await vscode.workspace.openTextDocument( 84 | vscode.Uri.file( 85 | pathlib.join( 86 | __dirname, 87 | '../../src/test/fixtures/semantic_errors/package.json', 88 | ), 89 | ), 90 | ); 91 | await vscode.window.showTextDocument(doc); 92 | const diagnostics = await getDiagnostics(doc); 93 | assert.equal( 94 | diagnostics.map((d) => d.message), 95 | [ 96 | 'This command should just be "wireit", as this script is configured in the wireit section.', 97 | 'A wireit config must set at least one of "command", "dependencies", or "files". Otherwise there is nothing for wireit to do.', 98 | ], 99 | JSON.stringify(diagnostics.map((d) => d.message)), 100 | ); 101 | assert.equal( 102 | diagnostics.map((d) => ({ 103 | start: {line: d.range.start.line, character: d.range.start.character}, 104 | end: {line: d.range.end.line, character: d.range.end.character}, 105 | })), 106 | [ 107 | {start: {line: 2, character: 26}, end: {line: 2, character: 31}}, 108 | {start: {line: 17, character: 4}, end: {line: 17, character: 38}}, 109 | ], 110 | JSON.stringify( 111 | diagnostics.map((d) => ({ 112 | start: {line: d.range.start.line, character: d.range.start.character}, 113 | end: {line: d.range.end.line, character: d.range.end.character}, 114 | })), 115 | ), 116 | ); 117 | }); 118 | 119 | export {test}; 120 | -------------------------------------------------------------------------------- /vscode-extension/src/test/scripts/runner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import * as path from 'path'; 8 | 9 | import {runTests} from '@vscode/test-electron'; 10 | 11 | const MAX_TRIES = 3; 12 | 13 | /** 14 | * Runs the tests via `run` up to 3 times, because downloading vscode is 15 | * flaky. 16 | */ 17 | async function main() { 18 | for (let i = 0; i < MAX_TRIES - 1; i++) { 19 | try { 20 | await run(); 21 | return; 22 | } catch { 23 | console.error('Failed to run tests, retrying...'); 24 | } 25 | // wait a few seconds before retrying 26 | await new Promise((resolve) => setTimeout(resolve, 5_000)); 27 | } 28 | await run(); 29 | } 30 | 31 | /** 32 | * Downloads vscode and starts it in extension test mode, pointing it at 33 | * ./uvu-entrypoint 34 | * 35 | * Note that uvu-entrypoint runs in its own process, inside of electron. 36 | */ 37 | async function run() { 38 | const extensionDevelopmentPath = path.resolve(__dirname, '../../../built'); 39 | const extensionTestsPath = path.resolve(__dirname, './uvu-entrypoint.js'); 40 | await runTests({extensionDevelopmentPath, extensionTestsPath}); 41 | } 42 | 43 | main().catch((err: unknown) => { 44 | if (err === 'Failed') { 45 | // The tests failed in a normal way, so the error has already been logged 46 | // by uvu. All we need to do here is just to exit with a nonzero code. 47 | } else { 48 | console.error('Failed to run tests:'); 49 | console.error(err); 50 | } 51 | process.exit(1); 52 | }); 53 | -------------------------------------------------------------------------------- /vscode-extension/src/test/scripts/uvu-entrypoint.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | import {test} from '../main.test.js'; 8 | 9 | /** 10 | * The vscode test runner expects a function named `run` to be exported, 11 | * and to return a promise that resolves once the tests are done. 12 | * 13 | * This code ensures that we run all of our tests, and wait for them to 14 | * finish. 15 | * 16 | * uvu itself handles setting the process exit code to a non-zero value 17 | * if any tests fail. 18 | */ 19 | export async function run(): Promise { 20 | process.exitCode = 0; 21 | const finished = new Promise((resolve) => { 22 | test.after(() => { 23 | resolve(); 24 | }); 25 | }); 26 | test.run(); 27 | // wait for the test to finish 28 | await finished; 29 | // wait for a tick of the microtask queue so uvu can finish processing results 30 | await new Promise((resolve) => setTimeout(resolve, 0)); 31 | // our caller doesn't care about the exitCode though, they just care about 32 | // whether we return or throw 33 | if (process.exitCode !== 0) { 34 | // uvu has already logged the failure to the console so we don't need to 35 | // rejecting with an empty string does the least amount of repeated logging 36 | throw ''; 37 | } 38 | 39 | // yay tests pass! 40 | } 41 | -------------------------------------------------------------------------------- /vscode-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "useDefineForClassFields": true, 7 | "lib": ["es2021"], 8 | "rootDir": "src", 9 | "outDir": "lib", 10 | "strict": true, 11 | "sourceMap": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true, 16 | "useUnknownInCatchVariables": true 17 | }, 18 | "include": ["src/**/*.ts"], 19 | "exclude": [] 20 | } 21 | -------------------------------------------------------------------------------- /website/.eleventy.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2022 Google LLC 4 | * SPDX-License-Identifier: Apache-2.0 5 | */ 6 | 7 | const markdownIt = require('markdown-it'); 8 | const markdownItAnchor = require('markdown-it-anchor'); 9 | const pathlib = require('path'); 10 | 11 | module.exports = function (eleventyConfig) { 12 | eleventyConfig.addPlugin(require('@11ty/eleventy-navigation')); 13 | eleventyConfig.addPlugin(require('@11ty/eleventy-plugin-syntaxhighlight')); 14 | 15 | eleventyConfig.addPassthroughCopy({ 16 | 'content/_static': '.', 17 | [require.resolve('prism-themes/themes/prism-ghcolors.css')]: 18 | 'prism-light.css', 19 | [require.resolve('prism-themes/themes/prism-atom-dark.css')]: 20 | 'prism-dark.css', 21 | }); 22 | 23 | eleventyConfig.setLibrary( 24 | 'md', 25 | markdownIt({ 26 | html: true, 27 | }).use(markdownItAnchor, { 28 | level: 3, 29 | permalink: markdownItAnchor.permalink.headerLink(), 30 | }), 31 | ); 32 | 33 | /** 34 | * Generate a relative path to the root from the given page URL. 35 | * 36 | * Useful when a template which is used from different directories needs to 37 | * reliably refer to a path with a relative URL, so that the site can be 38 | * served from different sub-directories. 39 | * 40 | * Example: 41 | * / --> . 42 | * /foo/ --> .. 43 | * /foo/bar/ --> ../.. 44 | * 45 | * (It sort of seems like this should be built-in. There's the "url" filter, 46 | * but it produces paths that don't depend on the current URL). 47 | */ 48 | eleventyConfig.addFilter('relativePathToRoot', (url) => 49 | url === '/' ? '.' : pathlib.posix.relative(url, '/'), 50 | ); 51 | 52 | return { 53 | dir: { 54 | input: 'content', 55 | }, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /website/content/00-index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layout.njk 3 | title: Introduction 4 | permalink: index.html 5 | eleventyNavigation: 6 | key: Introduction 7 | order: 0 8 | --- 9 | 10 | ## Introduction 11 | 12 | Wireit upgrades your npm scripts to make them smarter and more efficient. 13 | 14 | ### Features 15 | 16 | - 🙂 Use the `npm run` commands you already know 17 | - ⛓️ Automatically run dependencies between npm scripts in parallel 18 | - 👀 Watch any script and continuously re-run on changes 19 | - 🥬 Skip scripts that are already fresh 20 | - ♻️ Cache output locally and remotely on GitHub Actions for free 21 | - 🛠️ Works with single packages, npm workspaces, and other monorepos 22 | 23 | ### Alpha 24 | 25 | > ### 🚧 Wireit is alpha software — in active but early development. You are welcome to try it out, but note there a number of [missing features and issues](https://github.com/google/wireit/issues) that you may run into! 🚧 26 | -------------------------------------------------------------------------------- /website/content/01-setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layout.njk 3 | title: Setup 4 | permalink: setup/index.html 5 | eleventyNavigation: 6 | key: Setup 7 | order: 1 8 | --- 9 | 10 | ## Setup 11 | 12 | ### Install 13 | 14 | ```bash 15 | npm i -D wireit 16 | ``` 17 | 18 | ### Setup 19 | 20 | Wireit works _with_ `npm run`, it doesn't replace it. To configure an NPM script 21 | for Wireit, move the command into a new `wireit` section of your `package.json`, 22 | and replace the original script with the `wireit` command. 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 41 | 57 | 58 |
BeforeAfter
31 | 32 | ```json 33 | { 34 | "scripts": { 35 | "build": "tsc" 36 | } 37 | } 38 | ``` 39 | 40 | 42 | 43 | ```json 44 | { 45 | "scripts": { 46 | "build": "wireit" 47 | }, 48 | "wireit": { 49 | "build": { 50 | "command": "tsc" 51 | } 52 | } 53 | } 54 | ``` 55 | 56 |
59 | 60 | Now when you run `npm run build`, Wireit upgrades the script to be smarter and 61 | more efficient. Wireit works with [yarn](https://yarnpkg.com/) and 62 | [pnpm](https://pnpm.io/), too. 63 | 64 | You should also add `.wireit` to your `.gitignore` file. Wireit uses the 65 | `.wireit` directory to store caches and other data for your scripts. 66 | 67 | ```bash 68 | echo .wireit >> .gitignore 69 | ``` 70 | 71 | ### VSCode Extension 72 | 73 | If you use VSCode, consider installing the `google.wireit` extension. It adds documentation on hover, autocomplete, can diagnose a number of common mistakes, and even suggest a refactoring to convert an npm script to use wireit. 74 | 75 | Install it [from the marketplace](https://marketplace.visualstudio.com/items?itemName=google.wireit) or on the command line like: 76 | 77 | ```bash 78 | code --install-extension google.wireit 79 | ``` 80 | -------------------------------------------------------------------------------- /website/content/02-dependencies.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: layout.njk 3 | title: Dependencies 4 | permalink: dependencies/index.html 5 | eleventyNavigation: 6 | key: Dependencies 7 | order: 2 8 | --- 9 | 10 | ## Dependencies 11 | 12 | To declare a dependency between two scripts, edit the 13 | `wireit. 44 | 45 | 46 | -------------------------------------------------------------------------------- /website/content/_static/style.css: -------------------------------------------------------------------------------- 1 | /* Light theme colors */ 2 | html { 3 | --background-color: #fff; 4 | --background-color-alt: #00000009; 5 | --text-color: #000; 6 | --link-color: #cb0040; 7 | --line-color: #ccc; 8 | --nav-background: #00000009; 9 | --nav-link-color: #333; 10 | --nav-active-link-color: #cb0040; 11 | --blockquote-stripe-color: #14df5c; 12 | } 13 | 14 | /* Dark theme colors */ 15 | @media (prefers-color-scheme: dark) { 16 | html { 17 | --background-color: #0d1117; 18 | --background-color-alt: #ffffff17; 19 | --text-color: #ddd; 20 | --link-color: #58a6ff; 21 | --line-color: #444; 22 | --nav-background: #000000a6; 23 | --nav-link-color: #ddd; 24 | --nav-active-link-color: #14df5c; 25 | --blockquote-stripe-color: #0c8838; 26 | } 27 | } 28 | 29 | body { 30 | background: var(--background-color); 31 | color: var(--text-color); 32 | display: grid; 33 | grid-template-areas: 'nav main'; 34 | grid-template-columns: 12em 1fr; 35 | margin: 0; 36 | height: 100vh; 37 | background: var(--background-color); 38 | font-family: sans-serif; 39 | font-size: 16px; 40 | line-height: 1.5em; 41 | } 42 | 43 | #skipnav { 44 | position: absolute; 45 | top: 0; 46 | left: -100%; 47 | background: inherit; 48 | padding: 0.5em; 49 | margin: 1em; 50 | color: var(--link-color); 51 | } 52 | #skipnav:focus { 53 | left: 0; 54 | } 55 | 56 | #badges { 57 | display: flex; 58 | flex-direction: column; 59 | justify-content: center; 60 | align-items: center; 61 | margin: 0 0 1em 0; 62 | } 63 | #githubStarsBadge { 64 | zoom: 109%; 65 | } 66 | 67 | code:not([class]) { 68 | background: var(--background-color-alt); 69 | padding: 0.2em 0.4em; 70 | border-radius: 6px; 71 | } 72 | 73 | code { 74 | font-size: 14px; 75 | } 76 | 77 | nav { 78 | grid-area: nav; 79 | line-height: 2em; 80 | background: var(--nav-background); 81 | overflow-y: auto; 82 | padding-bottom: 1em; 83 | } 84 | nav > img { 85 | padding: 1em; 86 | display: block; 87 | margin: auto; 88 | } 89 | nav > ul { 90 | padding: 0 1.5em 0 0; 91 | margin: 0; 92 | } 93 | nav > ul > li { 94 | display: flex; 95 | } 96 | nav > ul > li > a { 97 | flex: 1; 98 | color: var(--nav-link-color); 99 | text-decoration: none; 100 | text-align: right; 101 | } 102 | nav > ul > li > a:hover { 103 | text-decoration: underline; 104 | } 105 | nav > ul > li > a:visited { 106 | color: var(--nav-link-color); 107 | } 108 | nav > ul > li.active > a { 109 | color: var(--nav-active-link-color); 110 | font-weight: bold; 111 | font-style: italic; 112 | } 113 | 114 | main { 115 | grid-area: main; 116 | overflow-y: auto; 117 | padding: 0.5em 2em 2em 2em; 118 | } 119 | 120 | article { 121 | max-width: 45em; 122 | } 123 | article a:not(.header-anchor), 124 | article a:not(.header-anchor):visited { 125 | color: var(--link-color); 126 | } 127 | article a.header-anchor:hover { 128 | text-decoration: underline; 129 | } 130 | article > img { 131 | display: block; 132 | margin: auto; 133 | } 134 | 135 | h2 { 136 | font-weight: 300; 137 | font-size: 2em; 138 | line-height: 1em; 139 | padding-bottom: 0.5em; 140 | border-bottom: 1px solid var(--line-color); 141 | margin-top: 20px; 142 | } 143 | 144 | h1 > a, 145 | h2 > a, 146 | h3 > a, 147 | h4 > a, 148 | h5 > a, 149 | h6 > a { 150 | text-decoration: none; 151 | color: inherit; 152 | } 153 | 154 | blockquote { 155 | background: var(--background-color-alt); 156 | border-left: 5px solid var(--blockquote-stripe-color); 157 | margin-left: 1em; 158 | padding: 0.1em 1.5em; 159 | } 160 | 161 | table { 162 | border-collapse: collapse; 163 | } 164 | tr:nth-child(odd) > td { 165 | background: var(--background-color-alt); 166 | } 167 | th, 168 | td { 169 | border: 1px solid var(--line-color); 170 | padding: 0.5em; 171 | vertical-align: top; 172 | } 173 | 174 | /* Mobile layout */ 175 | @media (max-width: 640px) { 176 | body { 177 | display: block; 178 | } 179 | nav > ul { 180 | display: grid; 181 | grid-template-columns: repeat(auto-fill, 180px); 182 | padding: 0 0 0 40px; 183 | } 184 | nav > ul > li { 185 | display: list-item; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /website/content/_static/wireit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wireit/internal-website", 3 | "description": "The website for wireit", 4 | "private": true, 5 | "version": "0.0.0", 6 | "author": "Google LLC", 7 | "license": "Apache-2.0", 8 | "type": "module", 9 | "scripts": { 10 | "build": "wireit", 11 | "serve": "wireit" 12 | }, 13 | "wireit": { 14 | "build": { 15 | "command": "eleventy --config=.eleventy.cjs", 16 | "files": [ 17 | ".eleventy.cjs", 18 | "content/**" 19 | ], 20 | "output": [ 21 | "_site/**" 22 | ], 23 | "clean": "if-file-deleted" 24 | }, 25 | "serve": { 26 | "command": "wds --root-dir=_site --watch --open", 27 | "dependencies": [ 28 | "build" 29 | ], 30 | "files": [], 31 | "output": [] 32 | } 33 | }, 34 | "dependencies": { 35 | "@11ty/eleventy": "^2.0.0", 36 | "@11ty/eleventy-navigation": "^0.3.3", 37 | "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", 38 | "@web/dev-server": "^0.4.1", 39 | "markdown-it": "^14.0.0", 40 | "markdown-it-anchor": "^9.0.1", 41 | "prism-themes": "^1.9.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /wireit.svg: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------------------------------