diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 05a07e125484..73f4cc2aec65 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -88,13 +88,25 @@ DEBUG=vite:[name] astro dev   # debug specific process, e.g. "vite:deps" or "vit
 # run this in the top-level project root to run all tests
 pnpm run test
 # run only a few tests in the `astro` package, great for working on a single feature
-# (example - `pnpm run test:match "cli"` runs `cli.test.js`)
+# (example - `pnpm run test:match "cli"` runs tests with "cli" in the name)
 pnpm run test:match "$STRING_MATCH"
 # run tests on another package
 # (example - `pnpm --filter @astrojs/rss run test` runs `packages/astro-rss/test/rss.test.js`)
 pnpm --filter $STRING_MATCH run test
 ```
 
+Most tests use [`mocha`](https://mochajs.org) as the test runner. We're slowly migrating to use [`node:test`](https://nodejs.org/api/test.html) instead through the custom [`astro-scripts test`](./scripts/cmd/test.js) command. For packages that use `node:test`, you can run these commands in their directories:
+
+```shell
+# run all of the package's tests
+pnpm run test
+# run only a few tests in the package
+# (example - `pnpm run test -m "cli"` runs tests with "cli" in the name)
+pnpm run test -m "$STRING_MATCH"
+# run a single test file, you can use `node --test` directly
+node --test ./test/foo.test.js
+```
+
 #### E2E tests
 
 Certain features, like HMR and client hydration, need end-to-end tests to verify functionality in the dev server. [Playwright](https://playwright.dev/) is used to test against the dev server.
diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json
index 802b180157ec..a15c90616ed7 100644
--- a/packages/upgrade/package.json
+++ b/packages/upgrade/package.json
@@ -20,7 +20,7 @@
     "build": "astro-scripts build \"src/index.ts\" --bundle && tsc",
     "build:ci": "astro-scripts build \"src/index.ts\" --bundle",
     "dev": "astro-scripts dev \"src/**/*.ts\"",
-    "test": "mocha --exit --timeout 20000 --parallel"
+    "test": "astro-scripts test \"test/**/*.test.js\""
   },
   "files": [
     "dist",
@@ -39,8 +39,6 @@
     "@types/which-pm-runs": "^1.0.0",
     "arg": "^5.0.2",
     "astro-scripts": "workspace:*",
-    "chai": "^4.3.7",
-    "mocha": "^10.2.0",
     "strip-ansi": "^7.1.0"
   },
   "engines": {
diff --git a/packages/upgrade/test/context.test.js b/packages/upgrade/test/context.test.js
index 5b6b8c6b2201..714a7b64ac43 100644
--- a/packages/upgrade/test/context.test.js
+++ b/packages/upgrade/test/context.test.js
@@ -1,19 +1,20 @@
-import { expect } from 'chai';
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert/strict';
 import { getContext } from '../dist/index.js';
 
 describe('context', () => {
 	it('no arguments', async () => {
 		const ctx = await getContext([]);
-		expect(ctx.version).to.eq('latest');
-		expect(ctx.dryRun).to.be.undefined;
+		assert.equal(ctx.version, 'latest');
+		assert.equal(ctx.dryRun, undefined);
 	});
 	it('tag', async () => {
 		const ctx = await getContext(['beta']);
-		expect(ctx.version).to.eq('beta');
-		expect(ctx.dryRun).to.be.undefined;
+		assert.equal(ctx.version, 'beta');
+		assert.equal(ctx.dryRun, undefined);
 	});
 	it('dry run', async () => {
 		const ctx = await getContext(['--dry-run']);
-		expect(ctx.dryRun).to.eq(true);
+		assert.equal(ctx.dryRun, true);
 	});
 });
diff --git a/packages/upgrade/test/install.test.js b/packages/upgrade/test/install.test.js
index 05c46cdce9ef..b4158d264845 100644
--- a/packages/upgrade/test/install.test.js
+++ b/packages/upgrade/test/install.test.js
@@ -1,4 +1,5 @@
-import { expect } from 'chai';
+import { describe, it } from 'node:test';
+import * as assert from 'node:assert/strict';
 import { setup } from './utils.js';
 import { install } from '../dist/index.js';
 
@@ -23,7 +24,7 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('◼  astro is up to date on v1.0.0')).to.be.true;
+		assert.equal(fixture.hasMessage('◼  astro is up to date on v1.0.0'), true);
 	});
 
 	it('patch', async () => {
@@ -38,7 +39,7 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('●  astro can be updated to v1.0.1')).to.be.true;
+		assert.equal(fixture.hasMessage('●  astro can be updated to v1.0.1'), true);
 	});
 
 	it('minor', async () => {
@@ -53,7 +54,7 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('●  astro can be updated to v1.2.0')).to.be.true;
+		assert.equal(fixture.hasMessage('●  astro can be updated to v1.2.0'), true);
 	});
 
 	it('major (reject)', async () => {
@@ -80,10 +81,10 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('▲  astro can be updated to  v2.0.0')).to.be.true;
-		expect(prompted).to.be.true;
-		expect(exitCode).to.eq(0);
-		expect(fixture.hasMessage('check   Be sure to follow the CHANGELOG.')).to.be.false;
+		assert.equal(fixture.hasMessage('▲  astro can be updated to  v2.0.0'), true);
+		assert.equal(prompted, true);
+		assert.equal(exitCode, 0);
+		assert.equal(fixture.hasMessage('check   Be sure to follow the CHANGELOG.'), false);
 	});
 
 	it('major (accept)', async () => {
@@ -110,10 +111,10 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('▲  astro can be updated to  v2.0.0')).to.be.true;
-		expect(prompted).to.be.true;
-		expect(exitCode).to.be.undefined;
-		expect(fixture.hasMessage('check   Be sure to follow the CHANGELOG.')).to.be.true;
+		assert.equal(fixture.hasMessage('▲  astro can be updated to  v2.0.0'), true);
+		assert.equal(prompted, true);
+		assert.equal(exitCode, undefined);
+		assert.equal(fixture.hasMessage('check   Be sure to follow the CHANGELOG.'), true);
 	});
 
 	it('multiple major', async () => {
@@ -148,14 +149,14 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('▲  a can be updated to  v2.0.0')).to.be.true;
-		expect(fixture.hasMessage('▲  b can be updated to  v7.0.0')).to.be.true;
-		expect(prompted).to.be.true;
-		expect(exitCode).to.be.undefined;
+		assert.equal(fixture.hasMessage('▲  a can be updated to  v2.0.0'), true);
+		assert.equal(fixture.hasMessage('▲  b can be updated to  v7.0.0'), true);
+		assert.equal(prompted, true);
+		assert.equal(exitCode, undefined);
 		const [changelog, a, b] = fixture.messages().slice(-5);
-		expect(changelog).to.match(/^check/);
-		expect(a).to.match(/^a/);
-		expect(b).to.match(/^b/);
+		assert.match(changelog, /^check/);
+		assert.match(a, /^a/);
+		assert.match(b, /^b/);
 	});
 
 	it('current patch minor major', async () => {
@@ -197,15 +198,15 @@ describe('install', () => {
 			],
 		};
 		await install(context);
-		expect(fixture.hasMessage('◼  current is up to date on v1.0.0')).to.be.true;
-		expect(fixture.hasMessage('●  patch can be updated to v1.0.1')).to.be.true;
-		expect(fixture.hasMessage('●  minor can be updated to v1.2.0')).to.be.true;
-		expect(fixture.hasMessage('▲  major can be updated to  v3.0.0')).to.be.true;
-		expect(prompted).to.be.true;
-		expect(exitCode).to.be.undefined;
-		expect(fixture.hasMessage('check   Be sure to follow the CHANGELOG.')).to.be.true;
+		assert.equal(fixture.hasMessage('◼  current is up to date on v1.0.0'), true);
+		assert.equal(fixture.hasMessage('●  patch can be updated to v1.0.1'), true);
+		assert.equal(fixture.hasMessage('●  minor can be updated to v1.2.0'), true);
+		assert.equal(fixture.hasMessage('▲  major can be updated to  v3.0.0'), true);
+		assert.equal(prompted, true);
+		assert.equal(exitCode, undefined);
+		assert.equal(fixture.hasMessage('check   Be sure to follow the CHANGELOG.'), true);
 		const [changelog, major] = fixture.messages().slice(-4);
-		expect(changelog).to.match(/^check/);
-		expect(major).to.match(/^major/);
+		assert.match(changelog, /^check/);
+		assert.match(major, /^major/)
 	});
 });
diff --git a/packages/upgrade/test/utils.js b/packages/upgrade/test/utils.js
index ff5d5dd832af..691e63d90a81 100644
--- a/packages/upgrade/test/utils.js
+++ b/packages/upgrade/test/utils.js
@@ -1,3 +1,4 @@
+import { before, beforeEach } from 'node:test';
 import { setStdout } from '../dist/index.js';
 import stripAnsi from 'strip-ansi';
 
diff --git a/packages/upgrade/test/verify.test.js b/packages/upgrade/test/verify.test.js
index a54cb6bb5085..3b9d4b3bc12d 100644
--- a/packages/upgrade/test/verify.test.js
+++ b/packages/upgrade/test/verify.test.js
@@ -1,4 +1,5 @@
-import { expect } from 'chai';
+import { describe, it, beforeEach } from 'node:test';
+import * as assert from 'node:assert/strict';
 import { collectPackageInfo } from '../dist/index.js';
 
 describe('collectPackageInfo', () => {
@@ -16,61 +17,61 @@ describe('collectPackageInfo', () => {
 
 	it('detects astro', async () => {
 		collectPackageInfo(context, { astro: '1.0.0' }, {});
-		expect(context.packages).deep.equal([
+		assert.deepEqual(context.packages, [
 			{ name: 'astro', currentVersion: '1.0.0', targetVersion: 'latest' },
 		]);
 	});
 
 	it('detects @astrojs', async () => {
 		collectPackageInfo(context, { '@astrojs/preact': '1.0.0' }, {});
-		expect(context.packages).deep.equal([
+		assert.deepEqual(context.packages, [
 			{ name: '@astrojs/preact', currentVersion: '1.0.0', targetVersion: 'latest' },
 		]);
 	});
 
 	it('supports ^ prefixes', async () => {
 		collectPackageInfo(context, { astro: '^1.0.0' }, {});
-		expect(context.packages).deep.equal([
+		assert.deepEqual(context.packages, [
 			{ name: 'astro', currentVersion: '^1.0.0', targetVersion: 'latest' },
 		]);
 	});
 
 	it('supports ~ prefixes', async () => {
 		collectPackageInfo(context, { astro: '~1.0.0' }, {});
-		expect(context.packages).deep.equal([
+		assert.deepEqual(context.packages, [
 			{ name: 'astro', currentVersion: '~1.0.0', targetVersion: 'latest' },
 		]);
 	});
 
 	it('supports prereleases', async () => {
 		collectPackageInfo(context, { astro: '1.0.0-beta.0' }, {});
-		expect(context.packages).deep.equal([
+		assert.deepEqual(context.packages, [
 			{ name: 'astro', currentVersion: '1.0.0-beta.0', targetVersion: 'latest' },
 		]);
 	});
 
 	it('ignores self', async () => {
 		collectPackageInfo(context, { '@astrojs/upgrade': '0.0.1' }, {});
-		expect(context.packages).deep.equal([]);
+		assert.deepEqual(context.packages, []);
 	});
 
 	it('ignores linked packages', async () => {
 		collectPackageInfo(context, { '@astrojs/preact': 'link:../packages/preact' }, {});
-		expect(context.packages).deep.equal([]);
+		assert.deepEqual(context.packages, []);
 	});
 
 	it('ignores workspace packages', async () => {
 		collectPackageInfo(context, { '@astrojs/preact': 'workspace:*' }, {});
-		expect(context.packages).deep.equal([]);
+		assert.deepEqual(context.packages, []);
 	});
 
 	it('ignores github packages', async () => {
 		collectPackageInfo(context, { '@astrojs/preact': 'github:withastro/astro' }, {});
-		expect(context.packages).deep.equal([]);
+		assert.deepEqual(context.packages, []);
 	});
 
 	it('ignores tag', async () => {
 		collectPackageInfo(context, { '@astrojs/preact': 'beta' }, {});
-		expect(context.packages).deep.equal([]);
+		assert.deepEqual(context.packages, []);
 	});
 });
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1a32f4441b3..ab455435e4b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5148,12 +5148,6 @@ importers:
       astro-scripts:
         specifier: workspace:*
         version: link:../../scripts
-      chai:
-        specifier: ^4.3.7
-        version: 4.3.10
-      mocha:
-        specifier: ^10.2.0
-        version: 10.2.0
       strip-ansi:
         specifier: ^7.1.0
         version: 7.1.0
diff --git a/scripts/cmd/test.js b/scripts/cmd/test.js
index e69de29bb2d1..84f6d9742187 100644
--- a/scripts/cmd/test.js
+++ b/scripts/cmd/test.js
@@ -0,0 +1,51 @@
+import { run } from 'node:test';
+import { spec } from 'node:test/reporters';
+import arg from 'arg';
+import glob from 'tiny-glob';
+
+const isCI = !!process.env.CI;
+const defaultTimeout = isCI ? 30000 : 20000;
+
+export default async function test() {
+	const args = arg({
+		'--match': String, // aka --test-name-pattern: https://nodejs.org/api/test.html#filtering-tests-by-name
+		'--only': Boolean, // aka --test-only: https://nodejs.org/api/test.html#only-tests
+		'--parallel': Boolean, // aka --test-concurrency: https://nodejs.org/api/test.html#test-runner-execution-model
+		'--watch': Boolean, // experimental: https://nodejs.org/api/test.html#watch-mode
+		'--timeout': Number, // Test timeout in milliseconds (default: 30000ms)
+		'--setup': String, // Test setup file
+		// Aliases
+		'-m': '--match',
+		'-o': '--only',
+		'-p': '--parallel',
+		'-w': '--watch',
+		'-t': '--timeout',
+		'-s': '--setup',
+	});
+
+	const pattern = args._[1];
+	if (!pattern) throw new Error('Missing test glob pattern');
+
+	const files = await glob(pattern, { filesOnly: true, absolute: true });
+
+	// For some reason, the `only` option does not work and we need to explicitly set the CLI flag instead.
+	// Node.js requires opt-in to run .only tests :(
+	// https://nodejs.org/api/test.html#only-tests
+	if (args['--only']) {
+		process.env.NODE_OPTIONS ??= '';
+		process.env.NODE_OPTIONS += ' --test-only';
+	}
+
+	// https://nodejs.org/api/test.html#runoptions
+	run({
+		files,
+		testNamePatterns: args['--match'],
+		concurrency: args['--parallel'],
+		only: args['--only'],
+		setup: args['--setup'],
+		watch: args['--watch'],
+		timeout: args['--timeout'] ?? defaultTimeout, // Node.js defaults to Infinity, so set better fallback
+	})
+		.pipe(new spec())
+		.pipe(process.stdout);
+}
diff --git a/scripts/index.js b/scripts/index.js
index 249eac53d135..381500ac4e69 100755
--- a/scripts/index.js
+++ b/scripts/index.js
@@ -18,6 +18,11 @@ export default async function run() {
 			await prebuild(...args);
 			break;
 		}
+		case 'test': {
+			const { default: test } = await import('./cmd/test.js');
+			await test(...args);
+			break;
+		}
 	}
 }