0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat(cli): add --watch to astro check command (#6356)

* feat(cli): add `--watch` to `astro check` command

* chore: refactor in a leaner way, logic not changed

* chore: lint

* chore: revert changes in sync command

* chore: tweak server settings

* test: add one test case

* chore: increase timeout

* test: predictable testing

* chore: add changeset

* chore: code suggestions

* code suggestions

* chore: use directly `chokidar`

* chore: tweak code

* fix: open documents first

* chore: disable test

* chore: code suggestions

* chore: code suggestions

* Apply suggestions from code review

Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>

* code suggestions

* chore: rebase

---------

Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
This commit is contained in:
Emanuele Stoppa 2023-03-07 06:55:41 +00:00 committed by GitHub
parent 1291afc09d
commit 75921b3cd9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 472 additions and 74 deletions

View file

@ -0,0 +1,5 @@
---
'astro': minor
---
Added a new `--watch` flag to the command `astro check`

View file

@ -113,6 +113,7 @@
"@types/yargs-parser": "^21.0.0",
"acorn": "^8.8.1",
"boxen": "^6.2.1",
"chokidar": "^3.5.3",
"ci-info": "^3.3.1",
"common-ancestor-path": "^1.0.1",
"cookie": "^0.5.0",

View file

@ -1,5 +1,5 @@
/* eslint-disable no-console */
import { AstroCheck, DiagnosticSeverity } from '@astrojs/language-server';
import { AstroCheck, DiagnosticSeverity, GetDiagnosticsResult } from '@astrojs/language-server';
import glob from 'fast-glob';
import * as fs from 'fs';
import { bold, dim, red, yellow } from 'kleur/colors';
@ -11,18 +11,77 @@ import type { AstroSettings } from '../../@types/astro';
import type { LogOptions } from '../../core/logger/core.js';
import { printHelp } from '../../core/messages.js';
import { printDiagnostic } from './print.js';
import type { Arguments as Flags } from 'yargs-parser';
import { debug, info } from '../../core/logger/core.js';
import type { ProcessExit, SyncOptions } from '../../core/sync';
import fsMod from 'fs';
import type { FSWatcher } from 'chokidar';
import { join } from 'node:path';
interface Result {
type DiagnosticResult = {
errors: number;
// The language server cannot actually return any warnings at the moment, but we'll keep this here for future use
warnings: number;
hints: number;
};
export type CheckPayload = {
/**
* Flags passed via CLI
*/
flags: Flags;
/**
* Logging options
*/
logging: LogOptions;
};
type CheckFlags = {
/**
* Whether the `check` command should watch for `.astro` and report errors
* @default {false}
*/
watch: boolean;
};
/**
*
* Types of response emitted by the checker
*/
export enum CheckResult {
/**
* Operation finished without errors
*/
ExitWithSuccess,
/**
* Operation finished with errors
*/
ExitWithError,
/**
* The consumer should not terminate the operation
*/
Listen,
}
const ASTRO_GLOB_PATTERN = '**/*.astro';
/**
* Checks `.astro` files for possible errors.
*
* If the `--watch` flag is provided, the command runs indefinitely and provides diagnostics
* when `.astro` files are modified.
*
* Every time an astro files is modified, content collections are also generated.
*
* @param {AstroSettings} settings
* @param {CheckPayload} options Options passed {@link AstroChecker}
* @param {Flags} options.flags Flags coming from the CLI
* @param {LogOptions} options.logging Logging options
*/
export async function check(
settings: AstroSettings,
{ logging, flags }: { logging: LogOptions; flags: Arguments }
) {
{ logging, flags }: CheckPayload
): Promise<AstroChecker | undefined> {
if (flags.help || flags.h) {
printHelp({
commandName: 'astro check',
@ -34,66 +93,256 @@ export async function check(
});
return;
}
console.log(bold('astro check'));
const checkFlags = parseFlags(flags);
if (checkFlags.watch) {
info(logging, 'check', 'Checking files in watch mode');
} else {
info(logging, 'check', 'Checking files');
}
const { syncCli } = await import('../../core/sync/index.js');
const syncRet = await syncCli(settings, { logging, fs, flags });
// early exit on sync failure
if (syncRet === 1) return syncRet;
const root = settings.config.root;
const spinner = ora(` Getting diagnostics for Astro files in ${fileURLToPath(root)}`).start();
const require = createRequire(import.meta.url);
let checker = new AstroCheck(
const diagnosticChecker = new AstroCheck(
root.toString(),
require.resolve('typescript/lib/tsserverlibrary.js', { paths: [root.toString()] })
require.resolve('typescript/lib/tsserverlibrary.js', {
paths: [root.toString()],
})
);
const filesCount = await openAllDocuments(root, [], checker);
let diagnostics = await checker.getDiagnostics();
spinner.succeed();
let result: Result = {
errors: 0,
warnings: 0,
hints: 0,
};
diagnostics.forEach((diag) => {
diag.diagnostics.forEach((d) => {
console.log(printDiagnostic(diag.fileUri, diag.text, d));
switch (d.severity) {
case DiagnosticSeverity.Error: {
result.errors++;
break;
}
case DiagnosticSeverity.Warning: {
result.warnings++;
break;
}
case DiagnosticSeverity.Hint: {
result.hints++;
break;
}
}
});
return new AstroChecker({
syncCli,
settings,
fileSystem: fs,
logging,
diagnosticChecker,
isWatchMode: checkFlags.watch,
});
}
console.log(
[
bold(`Result (${filesCount} file${filesCount === 1 ? '' : 's'}): `),
bold(red(`${result.errors} ${result.errors === 1 ? 'error' : 'errors'}`)),
bold(yellow(`${result.warnings} ${result.warnings === 1 ? 'warning' : 'warnings'}`)),
dim(`${result.hints} ${result.hints === 1 ? 'hint' : 'hints'}\n`),
].join(`\n${dim('-')} `)
);
type CheckerConstructor = {
diagnosticChecker: AstroCheck;
const exitCode = result.errors ? 1 : 0;
return exitCode;
isWatchMode: boolean;
syncCli: (settings: AstroSettings, options: SyncOptions) => Promise<ProcessExit>;
settings: Readonly<AstroSettings>;
logging: Readonly<LogOptions>;
fileSystem: typeof fsMod;
};
/**
* Responsible to check files - classic or watch mode - and report diagnostics.
*
* When in watch mode, the class does a whole check pass, and then starts watching files.
* When a change occurs to an `.astro` file, the checker builds content collections again and lint all the `.astro` files.
*/
export class AstroChecker {
readonly #diagnosticsChecker: AstroCheck;
readonly #shouldWatch: boolean;
readonly #syncCli: (settings: AstroSettings, opts: SyncOptions) => Promise<ProcessExit>;
readonly #settings: AstroSettings;
readonly #logging: LogOptions;
readonly #fs: typeof fsMod;
#watcher?: FSWatcher;
#filesCount: number;
#updateDiagnostics: NodeJS.Timeout | undefined;
constructor({
diagnosticChecker,
isWatchMode,
syncCli,
settings,
fileSystem,
logging,
}: CheckerConstructor) {
this.#diagnosticsChecker = diagnosticChecker;
this.#shouldWatch = isWatchMode;
this.#syncCli = syncCli;
this.#logging = logging;
this.#settings = settings;
this.#fs = fileSystem;
this.#filesCount = 0;
}
/**
* Check all `.astro` files once and then finishes the operation.
*/
public async check(): Promise<CheckResult> {
return await this.#checkAllFiles(true);
}
/**
* Check all `.astro` files and then start watching for changes.
*/
public async watch(): Promise<CheckResult> {
await this.#checkAllFiles(true);
await this.#watch();
return CheckResult.Listen;
}
/**
* Stops the watch. It terminates the inner server.
*/
public async stop() {
await this.#watcher?.close();
}
/**
* Whether the checker should run in watch mode
*/
public get isWatchMode(): boolean {
return this.#shouldWatch;
}
async #openDocuments() {
this.#filesCount = await openAllDocuments(
this.#settings.config.root,
[],
this.#diagnosticsChecker
);
}
/**
* Lint all `.astro` files, and report the result in console. Operations executed, in order:
* 1. Compile content collections.
* 2. Optionally, traverse the file system for `.astro` files and saves their paths.
* 3. Get diagnostics for said files and print the result in console.
*
* @param openDocuments Whether the operation should open all `.astro` files
*/
async #checkAllFiles(openDocuments: boolean): Promise<CheckResult> {
const processExit = await this.#syncCli(this.#settings, {
logging: this.#logging,
fs: this.#fs,
});
// early exit on sync failure
if (processExit === 1) return processExit;
let spinner = ora(
` Getting diagnostics for Astro files in ${fileURLToPath(this.#settings.config.root)}`
).start();
if (openDocuments) {
await this.#openDocuments();
}
let diagnostics = await this.#diagnosticsChecker.getDiagnostics();
spinner.succeed();
let brokenDownDiagnostics = this.#breakDownDiagnostics(diagnostics);
this.#logDiagnosticsSeverity(brokenDownDiagnostics);
return brokenDownDiagnostics.errors > 0
? CheckResult.ExitWithError
: CheckResult.ExitWithSuccess;
}
#checkForDiagnostics() {
clearTimeout(this.#updateDiagnostics);
// @ematipico: I am not sure of `setTimeout`. I would rather use a debounce but let's see if this works.
// Inspiration from `svelte-check`.
this.#updateDiagnostics = setTimeout(async () => await this.#checkAllFiles(false), 500);
}
/**
* This function is responsible to attach events to the server watcher
*/
async #watch() {
const { default: chokidar } = await import('chokidar');
this.#watcher = chokidar.watch(
join(fileURLToPath(this.#settings.config.root), ASTRO_GLOB_PATTERN),
{
ignored: ['**/node_modules/**'],
ignoreInitial: true,
}
);
this.#watcher.on('add', (file) => {
this.#addDocument(file);
this.#filesCount += 1;
this.#checkForDiagnostics();
});
this.#watcher.on('change', (file) => {
this.#addDocument(file);
this.#checkForDiagnostics();
});
this.#watcher.on('unlink', (file) => {
this.#diagnosticsChecker.removeDocument(file);
this.#filesCount -= 1;
this.#checkForDiagnostics();
});
}
/**
* Add a document to the diagnostics checker
* @param filePath Path to the file
*/
#addDocument(filePath: string) {
const text = fs.readFileSync(filePath, 'utf-8');
this.#diagnosticsChecker.upsertDocument({
uri: pathToFileURL(filePath).toString(),
text,
});
}
/**
* Logs the result of the various diagnostics
*
* @param result Result emitted by AstroChecker.#breakDownDiagnostics
*/
#logDiagnosticsSeverity(result: Readonly<DiagnosticResult>) {
info(
this.#logging,
'diagnostics',
[
bold(`Result (${this.#filesCount} file${this.#filesCount === 1 ? '' : 's'}): `),
bold(red(`${result.errors} ${result.errors === 1 ? 'error' : 'errors'}`)),
bold(yellow(`${result.warnings} ${result.warnings === 1 ? 'warning' : 'warnings'}`)),
dim(`${result.hints} ${result.hints === 1 ? 'hint' : 'hints'}\n`),
].join(`\n${dim('-')} `)
);
}
/**
* It loops through all diagnostics and break down diagnostics that are errors, warnings or hints.
*/
#breakDownDiagnostics(diagnostics: Readonly<GetDiagnosticsResult[]>): DiagnosticResult {
let result: DiagnosticResult = {
errors: 0,
warnings: 0,
hints: 0,
};
diagnostics.forEach((diag) => {
diag.diagnostics.forEach((d) => {
info(this.#logging, 'diagnostics', `\n ${printDiagnostic(diag.fileUri, diag.text, d)}`);
switch (d.severity) {
case DiagnosticSeverity.Error: {
result.errors++;
break;
}
case DiagnosticSeverity.Warning: {
result.warnings++;
break;
}
case DiagnosticSeverity.Hint: {
result.hints++;
break;
}
}
});
});
return result;
}
}
/**
@ -104,13 +353,14 @@ async function openAllDocuments(
filePathsToIgnore: string[],
checker: AstroCheck
): Promise<number> {
const files = await glob('**/*.astro', {
const files = await glob(ASTRO_GLOB_PATTERN, {
cwd: fileURLToPath(workspaceUri),
ignore: ['node_modules/**'].concat(filePathsToIgnore.map((ignore) => `${ignore}/**`)),
absolute: true,
});
for (const file of files) {
debug('check', `Adding file ${file} to the list of files to check.`);
const text = fs.readFileSync(file, 'utf-8');
checker.upsertDocument({
uri: pathToFileURL(file).toString(),
@ -120,3 +370,12 @@ async function openAllDocuments(
return files.length;
}
/**
* Parse flags and sets defaults
*/
function parseFlags(flags: Flags): CheckFlags {
return {
watch: flags.watch ?? false,
};
}

View file

@ -18,7 +18,7 @@ import { enableVerboseLogging, nodeLogDestination } from '../core/logger/node.js
import { formatConfigErrorMessage, formatErrorMessage, printHelp } from '../core/messages.js';
import * as event from '../events/index.js';
import { eventConfigError, eventError, telemetry } from '../events/index.js';
import { check } from './check/index.js';
import { check, CheckResult } from './check/index.js';
import { openInBrowser } from './open.js';
type Arguments = yargs.Arguments;
@ -222,15 +222,24 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
}
case 'check': {
const ret = await check(settings, { logging, flags });
return process.exit(ret);
// We create a server to start doing our operations
const checkServer = await check(settings, { flags, logging });
if (checkServer) {
if (checkServer.isWatchMode) {
await checkServer.watch();
return await new Promise(() => {}); // lives forever
} else {
let checkResult = await checkServer.check();
return process.exit(checkResult);
}
}
}
case 'sync': {
const { syncCli } = await import('../core/sync/index.js');
const ret = await syncCli(settings, { logging, fs, flags });
return process.exit(ret);
const result = await syncCli(settings, { logging, fs, flags });
return process.exit(result);
}
case 'preview': {

View file

@ -2,19 +2,24 @@ import { dim } from 'kleur/colors';
import type fsMod from 'node:fs';
import { performance } from 'node:perf_hooks';
import { createServer } from 'vite';
import { Arguments } from 'yargs-parser';
import type { AstroSettings } from '../../@types/astro';
import { createContentTypesGenerator } from '../../content/index.js';
import { globalContentConfigObserver } from '../../content/utils.js';
import { runHookConfigSetup } from '../../integrations/index.js';
import { setUpEnvTs } from '../../vite-plugin-inject-env-ts/index.js';
import { getTimeStat } from '../build/util.js';
import { createVite } from '../create-vite.js';
import { AstroError, AstroErrorData } from '../errors/index.js';
import { info, LogOptions } from '../logger/core.js';
import { printHelp } from '../messages.js';
import type { Arguments } from 'yargs-parser';
import { createVite } from '../create-vite.js';
type ProcessExit = 0 | 1;
export type ProcessExit = 0 | 1;
export type SyncOptions = {
logging: LogOptions;
fs: typeof fsMod;
};
export async function syncCli(
settings: AstroSettings,
@ -40,9 +45,20 @@ export async function syncCli(
return sync(resolvedSettings, { logging, fs });
}
/**
* Generate content collection types, and then returns the process exit signal.
*
* A non-zero process signal is emitted in case there's an error while generating content collection types.
*
* @param {SyncOptions} options
* @param {AstroSettings} settings Astro settings
* @param {typeof fsMod} options.fs The file system
* @param {LogOptions} options.logging Logging options
* @return {Promise<ProcessExit>}
*/
export async function sync(
settings: AstroSettings,
{ logging, fs }: { logging: LogOptions; fs: typeof fsMod }
{ logging, fs }: SyncOptions
): Promise<ProcessExit> {
const timerStart = performance.now();
// Needed to load content config

View file

@ -1,8 +1,11 @@
import { expect } from 'chai';
import { cli, parseCliDevStart, cliServerLogSetup } from './test-utils.js';
import { promises as fs } from 'fs';
import { fileURLToPath } from 'url';
import { isIPv4 } from 'net';
import { cli, parseCliDevStart, cliServerLogSetup, loadFixture } from './test-utils.js';
import stripAnsi from 'strip-ansi';
import { promises as fs, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isIPv4 } from 'node:net';
import { join } from 'node:path';
import { Writable } from 'node:stream';
describe('astro cli', () => {
const cliServerLogSetupWithFixture = (flags, cmd) => {
@ -15,6 +18,50 @@ describe('astro cli', () => {
expect(proc.exitCode).to.equal(0);
});
// Flaky test, in CI it exceeds the timeout most of the times
it.skip('astro check --watch reports errors on modified files', async () => {
let messageResolve;
const messagePromise = new Promise((resolve) => {
messageResolve = resolve;
});
const oneErrorContent = 'foobar';
/** @type {import('./test-utils').Fixture} */
const fixture = await loadFixture({
root: './fixtures/astro-check-watch/',
});
const logs = [];
const checkServer = await fixture.check({
flags: { watch: true },
logging: {
level: 'info',
dest: new Writable({
objectMode: true,
write(event, _, callback) {
logs.push({ ...event, message: stripAnsi(event.message) });
if (event.message.includes('1 error')) {
messageResolve(logs);
}
callback();
},
}),
},
});
await checkServer.watch();
const pagePath = join(fileURLToPath(fixture.config.root), 'src/pages/index.astro');
const pageContent = readFileSync(pagePath, 'utf-8');
await fs.writeFile(pagePath, oneErrorContent);
const messages = await messagePromise;
await fs.writeFile(pagePath, pageContent);
await checkServer.stop();
const diagnostics = messages.filter(
(m) => m.type === 'diagnostics' && m.message.includes('Result')
);
expect(diagnostics[0].message).to.include('0 errors');
expect(diagnostics[1].message).to.include('1 error');
}).timeout(35000);
it('astro --version', async () => {
const pkgURL = new URL('../package.json', import.meta.url);
const pkgVersion = await fs.readFile(pkgURL, 'utf8').then((data) => JSON.parse(data).version);
@ -144,7 +191,9 @@ describe('astro cli i18n', () => {
const projectRootURL = new URL('./fixtures/astro-basic/', import.meta.url);
let error = null;
try {
const proc = cli('dev', '--root', fileURLToPath(projectRootURL), { env: { LANG: locale } });
const proc = cli('dev', '--root', fileURLToPath(projectRootURL), {
env: { LANG: locale },
});
await parseCliDevStart(proc);
} catch (e) {
console.log(e);

View file

@ -0,0 +1,6 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
integrations: [],
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/astro-check-watch",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1 @@
<h1></h1>

View file

@ -0,0 +1,2 @@
// Having a `tsconfig.json`, even empty speeds up the test massively since TypeScript does not have to look for one
{}

View file

@ -12,6 +12,7 @@ import { createSettings } from '../dist/core/config/index.js';
import dev from '../dist/core/dev/index.js';
import { nodeLogDestination } from '../dist/core/logger/node.js';
import preview from '../dist/core/preview/index.js';
import { check } from '../dist/cli/check/index.js';
// polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16
polyfill(globalThis, {
@ -24,6 +25,8 @@ polyfill(globalThis, {
* @typedef {import('../src/@types/astro').AstroConfig} AstroConfig
* @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer
* @typedef {import('../src/core/app/index').App} App
* @typedef {import('../src/cli/check/index').AstroChecker} AstroChecker
* @typedef {import('../src/cli/check/index').CheckPayload} CheckPayload
*
*
* @typedef {Object} Fixture
@ -39,6 +42,24 @@ polyfill(globalThis, {
* @property {() => Promise<void>} clean
* @property {() => Promise<App>} loadTestAdapterApp
* @property {() => Promise<void>} onNextChange
* @property {(opts: CheckPayload) => Promise<AstroChecker>} check
*
* This function returns an instance of the Check
*
*
* When used in a test suite:
* ```js
* let fixture = await loadFixture({
* root: './fixtures/astro-check-watch/',
* });
* ```
* `opts` will override the options passed to the `AstroChecker`
*
* ```js
* let { check, stop, watch } = fixture.check({
* flags: { watch: true },
* });
* ```
*/
/** @type {import('../src/core/logger/core').LogOptions} */
@ -148,6 +169,9 @@ export async function loadFixture(inlineConfig) {
return build(settings, { logging, telemetry, ...opts });
},
sync: (opts) => sync(settings, { logging, fs, ...opts }),
check: async (opts) => {
return await check(settings, { logging, ...opts });
},
startDevServer: async (opts = {}) => {
process.env.NODE_ENV = 'development';
devServer = await dev(settings, { logging, telemetry, ...opts });
@ -160,7 +184,11 @@ export async function loadFixture(inlineConfig) {
fetch: (url, init) => fetch(resolveUrl(url), init),
preview: async (opts = {}) => {
process.env.NODE_ENV = 'production';
const previewServer = await preview(settings, { logging, telemetry, ...opts });
const previewServer = await preview(settings, {
logging,
telemetry,
...opts,
});
config.server.host = parseAddressToHost(previewServer.host); // update host
config.server.port = previewServer.port; // update port
return previewServer;
@ -177,7 +205,11 @@ export async function loadFixture(inlineConfig) {
cwd: fileURLToPath(config.outDir),
}),
clean: async () => {
await fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true });
await fs.promises.rm(config.outDir, {
maxRetries: 10,
recursive: true,
force: true,
});
},
loadTestAdapterApp: async (streaming) => {
const url = new URL(`./server/entry.mjs?id=${fixtureId}`, config.outDir);
@ -250,7 +282,9 @@ const cliPath = fileURLToPath(new URL('../astro.js', import.meta.url));
/** Returns a process running the Astro CLI. */
export function cli(/** @type {string[]} */ ...args) {
const spawned = execa('node', [cliPath, ...args], { env: { ASTRO_TELEMETRY_DISABLED: true } });
const spawned = execa('node', [cliPath, ...args], {
env: { ASTRO_TELEMETRY_DISABLED: true },
});
spawned.stdout.setEncoding('utf8');

View file

@ -10,4 +10,4 @@ interface PolyfillOptions {
override?: Record<string, {
(...args: any[]): any;
}>;
}
}

View file

@ -455,6 +455,7 @@ importers:
boxen: ^6.2.1
chai: ^4.3.6
cheerio: ^1.0.0-rc.11
chokidar: ^3.5.3
ci-info: ^3.3.1
common-ancestor-path: ^1.0.1
cookie: ^0.5.0
@ -521,6 +522,7 @@ importers:
'@types/yargs-parser': 21.0.0
acorn: 8.8.2
boxen: 6.2.1
chokidar: 3.5.3
ci-info: 3.7.1
common-ancestor-path: 1.0.1
cookie: 0.5.0
@ -1237,6 +1239,12 @@ importers:
dependencies:
astro: link:../../..
packages/astro/test/fixtures/astro-check-watch:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/astro-children:
specifiers:
'@astrojs/preact': workspace:*