diff --git a/.gitignore b/.gitignore index a40e76edc2af..9f2984d59aad 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ test/**/__screenshots__/**/* test/browser/fixtures/update-snapshot/basic.test.ts test/cli/fixtures/browser-multiple/basic-* .vitest-reports +.attest diff --git a/packages/snapshot/src/client.ts b/packages/snapshot/src/client.ts index cfa9508eb2d3..bb800475861f 100644 --- a/packages/snapshot/src/client.ts +++ b/packages/snapshot/src/client.ts @@ -43,6 +43,7 @@ interface AssertOptions { error?: Error errorMessage?: string rawSnapshot?: RawSnapshotInfo + skip?: boolean } export interface SnapshotClientOptions { @@ -102,6 +103,7 @@ export class SnapshotClient { error, errorMessage, rawSnapshot, + skip, } = options let { received } = options @@ -148,6 +150,7 @@ export class SnapshotClient { error, inlineSnapshot, rawSnapshot, + skip, }) if (!pass) { diff --git a/packages/snapshot/src/port/inlineSnapshot.ts b/packages/snapshot/src/port/inlineSnapshot.ts index 2f252017fa88..5a641477afee 100644 --- a/packages/snapshot/src/port/inlineSnapshot.ts +++ b/packages/snapshot/src/port/inlineSnapshot.ts @@ -116,23 +116,23 @@ function prepareSnapString(snap: string, source: string, index: number) { .replace(/\$\{/g, '\\${')}\n${indent}${quote}` } -const toMatchInlineName = 'toMatchInlineSnapshot' -const toThrowErrorMatchingInlineName = 'toThrowErrorMatchingInlineSnapshot' +const assertionNames = [ + 'toMatchInlineSnapshot', + 'toThrowErrorMatchingInlineSnapshot', + 'toMatchTypeInlineSnapshot', + 'toMatchTypeErrorInlineSnapshot', + 'toMatchTypeCompletionInlineSnapshot', +] // on webkit, the line number is at the end of the method, not at the start function getCodeStartingAtIndex(code: string, index: number) { - const indexInline = index - toMatchInlineName.length - if (code.slice(indexInline, index) === toMatchInlineName) { - return { - code: code.slice(indexInline), - index: indexInline, - } - } - const indexThrowInline = index - toThrowErrorMatchingInlineName.length - if (code.slice(index - indexThrowInline, index) === toThrowErrorMatchingInlineName) { - return { - code: code.slice(index - indexThrowInline), - index: index - indexThrowInline, + for (const name of assertionNames) { + const indexName = index - name.length + if (code.slice(indexName, index) === name) { + return { + code: code.slice(indexName), + index: indexName, + } } } return { @@ -141,8 +141,8 @@ function getCodeStartingAtIndex(code: string, index: number) { } } -const startRegex - = /(?:toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot)\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/ +const assertionNameRe = new RegExp(assertionNames.join('|')) +const startRegex = new RegExp(`(?:${assertionNameRe.source})${/\s*\(\s*(?:\/\*[\s\S]*\*\/\s*|\/\/.*(?:[\n\r\u2028\u2029]\s*|[\t\v\f \xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF]))*[\w$]*(['"`)])/.source}`) export function replaceInlineSnap( code: string, s: MagicString, @@ -153,9 +153,7 @@ export function replaceInlineSnap( const startMatch = startRegex.exec(codeStartingAtIndex) - const firstKeywordMatch = /toMatchInlineSnapshot|toThrowErrorMatchingInlineSnapshot/.exec( - codeStartingAtIndex, - ) + const firstKeywordMatch = codeStartingAtIndex.match(assertionNameRe) if (!startMatch || startMatch.index !== firstKeywordMatch?.index) { return replaceObjectSnap(code, s, index, newSnap) diff --git a/packages/snapshot/src/port/state.ts b/packages/snapshot/src/port/state.ts index d12f66746068..e9f30780a90c 100644 --- a/packages/snapshot/src/port/state.ts +++ b/packages/snapshot/src/port/state.ts @@ -20,7 +20,6 @@ import type { RawSnapshot, RawSnapshotInfo } from './rawSnapshot' import { parseErrorStacktrace } from '../../../utils/src/source-map' import { saveInlineSnapshots } from './inlineSnapshot' import { saveRawSnapshots } from './rawSnapshot' - import { addExtraLineBreaks, getSnapshotData, @@ -235,6 +234,7 @@ export default class SnapshotState { isInline, error, rawSnapshot, + skip, }: SnapshotMatchOptions): SnapshotReturnOptions { this._counters.set(testName, (this._counters.get(testName) || 0) + 1) const count = Number(this._counters.get(testName)) @@ -250,6 +250,11 @@ export default class SnapshotState { this._uncheckedKeys.delete(key) } + // allow no-op snapshot assertion for attest + if (skip) { + return { pass: true, actual: '', key, count } + } + let receivedSerialized = rawSnapshot && typeof received === 'string' ? (received as string) diff --git a/packages/snapshot/src/types/index.ts b/packages/snapshot/src/types/index.ts index b5378e5a8cb6..e38229aa995a 100644 --- a/packages/snapshot/src/types/index.ts +++ b/packages/snapshot/src/types/index.ts @@ -31,6 +31,7 @@ export interface SnapshotMatchOptions { isInline: boolean error?: Error rawSnapshot?: RawSnapshotInfo + skip?: boolean } export interface SnapshotResult { diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 553355eea59e..dafc41f98904 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -122,6 +122,7 @@ "dev": "NODE_OPTIONS=\"--max-old-space-size=8192\" rollup -c --watch -m inline" }, "peerDependencies": { + "@ark/attest": "*", "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "workspace:*", @@ -130,6 +131,9 @@ "jsdom": "*" }, "peerDependenciesMeta": { + "@ark/attest": { + "optional": true + }, "@edge-runtime/vm": { "optional": true }, @@ -174,6 +178,7 @@ "devDependencies": { "@ampproject/remapping": "^2.3.0", "@antfu/install-pkg": "^0.4.1", + "@ark/attest": "^0.28.0", "@edge-runtime/vm": "^4.0.4", "@sinonjs/fake-timers": "11.1.0", "@types/debug": "^4.1.12", diff --git a/packages/vitest/src/integrations/attest/node.ts b/packages/vitest/src/integrations/attest/node.ts new file mode 100644 index 000000000000..cda986f993c2 --- /dev/null +++ b/packages/vitest/src/integrations/attest/node.ts @@ -0,0 +1,18 @@ +import type { TestProject } from '../../node/project' +import { mkdirSync } from 'node:fs' +import path from 'node:path' + +export async function attestGlobalSetup(project: TestProject) { + process.env.ATTEST_attestAliases = JSON.stringify(['attest', 'expect']) + + const { writeAssertionData } = await import('@ark/attest') + const filepath = path.join(project.config.root, '.attest/assertions/typescript.json') + mkdirSync(path.dirname(filepath), { recursive: true }) + + function precache(): void { + return writeAssertionData(filepath) + } + + precache() + project.onTestsRerun(() => precache()) +} diff --git a/packages/vitest/src/integrations/attest/runtime.ts b/packages/vitest/src/integrations/attest/runtime.ts new file mode 100644 index 000000000000..ee6f6df5b5cc --- /dev/null +++ b/packages/vitest/src/integrations/attest/runtime.ts @@ -0,0 +1,220 @@ +import type { ChaiPlugin } from '@vitest/expect' +import type { + Plugin as PrettyFormatPlugin, +} from '@vitest/pretty-format' +import type { SerializedConfig } from '../../runtime/config' +import { addSerializer, stripSnapshotIndentation } from '@vitest/snapshot' +import { parseStacktrace } from '@vitest/utils/source-map' +import * as chai from 'chai' +import { getSnapshotClient, getTestNames } from '../snapshot/chai' + +// lazy load '@ark/attest` only when enabled +// TODO: can we read file .attest on our own? this is used only for `getTypeAssertionsAtPosition`. +// TODO: importing '@ark/attest' pulls entire 'typescript', so that should be avoided on runtime. +let lib: typeof import('@ark/attest') +let enabled = false + +const attestChai: ChaiPlugin = (chai, utils) => { + function getTypeAssertions(ctx: object) { + const parsed = parseStacktrace(utils.flag(ctx, '__vitest_expect_stack')) + const location = parsed[0] + const types = lib.getTypeAssertionsAtPosition({ + file: location.file, + method: location.method, + line: location.line, + char: location.column, + }) + return types + } + + function setupOptions(ctx: object, name: string) { + utils.flag(ctx, '_name', name) + const isNot = utils.flag(ctx, 'negate') + if (isNot) { + throw new Error(`"${name}" cannot be used with "not"`) + } + const test = utils.flag(ctx, 'vitest-test') + const options = getTestNames(test) + return { + skip: !enabled, + error: utils.flag(ctx, 'error'), + errorMessage: utils.flag(ctx, 'message'), + ...options, + } + } + + utils.addMethod( + chai.Assertion.prototype, + 'toMatchTypeSnapshot', + function ( + this: Record, + message?: string, + ) { + const options = setupOptions(this, 'toMatchTypeSnapshot') + let value: any + if (enabled) { + const types = getTypeAssertions(this) + value = types[0][1].args[0].type + } + getSnapshotClient().assert({ + received: new AttestSnapshotWrapper(value), + message, + ...options, + }) + }, + ) + utils.addMethod( + chai.Assertion.prototype, + 'toMatchTypeInlineSnapshot', + function __INLINE_SNAPSHOT__( + this: Record, + inlineSnapshot?: string, + message?: string, + ) { + const assertOptions = setupOptions(this, 'toMatchTypeInlineSnapshot') + if (inlineSnapshot) { + inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) + } + let value: any + if (enabled) { + const types = getTypeAssertions(this) + value = types[0][1].args[0].type + } + getSnapshotClient().assert({ + received: new AttestSnapshotWrapper(value), + message, + isInline: true, + inlineSnapshot, + ...assertOptions, + }) + }, + ) + utils.addMethod( + chai.Assertion.prototype, + 'toMatchTypeErrorSnapshot', + function ( + this: Record, + message?: string, + ) { + const options = setupOptions(this, 'toMatchTypeErrorSnapshot') + let value: any + if (enabled) { + const types = getTypeAssertions(this) + value = types[0][1].errors[0] + } + getSnapshotClient().assert({ + received: new AttestSnapshotWrapper(value), + message, + ...options, + }) + }, + ) + utils.addMethod( + chai.Assertion.prototype, + 'toMatchTypeErrorInlineSnapshot', + function __INLINE_SNAPSHOT__( + this: Record, + inlineSnapshot?: string, + message?: string, + ) { + const assertOptions = setupOptions(this, 'toMatchTypeErrorInlineSnapshot') + if (inlineSnapshot) { + inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) + } + let value: any + if (enabled) { + const types = getTypeAssertions(this) + value = types[0][1].errors[0] + } + getSnapshotClient().assert({ + received: new AttestSnapshotWrapper(value), + message, + isInline: true, + inlineSnapshot, + ...assertOptions, + }) + }, + ) + utils.addMethod( + chai.Assertion.prototype, + 'toMatchTypeCompletionSnapshot', + function ( + this: Record, + message?: string, + ) { + const options = setupOptions(this, 'toMatchTypeCompletionSnapshot') + let value: any + if (enabled) { + const types = getTypeAssertions(this) + value = types[0][1].completions + } + getSnapshotClient().assert({ + received: new AttestSnapshotWrapper(value), + message, + ...options, + }) + }, + ) + utils.addMethod( + chai.Assertion.prototype, + 'toMatchTypeCompletionInlineSnapshot', + function __INLINE_SNAPSHOT__( + this: Record, + inlineSnapshot?: string, + message?: string, + ) { + const assertOptions = setupOptions(this, 'toMatchTypeCompletionInlineSnapshot') + if (inlineSnapshot) { + inlineSnapshot = stripSnapshotIndentation(inlineSnapshot) + } + let value: any + if (enabled) { + const types = getTypeAssertions(this) + value = types[0][1].completions + } + getSnapshotClient().assert({ + received: new AttestSnapshotWrapper(value), + message, + isInline: true, + inlineSnapshot, + ...assertOptions, + }) + }, + ) +} + +class AttestSnapshotWrapper { + constructor(public value?: unknown) {} +} + +const attestPrettyFormat: PrettyFormatPlugin = { + test(val: unknown) { + return !!(val && val instanceof AttestSnapshotWrapper) + }, + serialize(val: AttestSnapshotWrapper, config, indentation, depth, refs, printer) { + if (typeof val.value === 'string') { + return val.value + } + return printer( + val.value, + config, + indentation, + depth, + refs, + ) + }, +} + +export async function attestSetup(config: SerializedConfig) { + chai.use(attestChai) + addSerializer(attestPrettyFormat) + enabled = config.attest + if (enabled) { + lib = await import('@ark/attest') + } + else { + if (typeof process !== 'undefined') { + process.env.ATTEST_skipTypes = 'true' + } + } +} diff --git a/packages/vitest/src/integrations/chai/index.ts b/packages/vitest/src/integrations/chai/index.ts index 1aea8249218f..d5666c404dc8 100644 --- a/packages/vitest/src/integrations/chai/index.ts +++ b/packages/vitest/src/integrations/chai/index.ts @@ -22,6 +22,10 @@ export function createExpect(test?: TaskPopulated) { setState({ assertionCalls: assertionCalls + 1 }, expect) const assert = chai.expect(value, message) as unknown as Assertion const _test = test || getCurrentTest() + if (getWorkerState().config.attest) { + // TODO: avoid incuring new Error for non attest assertion + chai.util.flag(assert, '__vitest_expect_stack', new Error('-').stack) + } if (_test) { // @ts-expect-error internal return assert.withTest(_test) as Assertion diff --git a/packages/vitest/src/integrations/snapshot/chai.ts b/packages/vitest/src/integrations/snapshot/chai.ts index f0eff3d94adc..fca4d52a4ee0 100644 --- a/packages/vitest/src/integrations/snapshot/chai.ts +++ b/packages/vitest/src/integrations/snapshot/chai.ts @@ -44,7 +44,7 @@ function getError(expected: () => void | Error, promise: string | undefined) { throw new Error('snapshot function didn\'t throw') } -function getTestNames(test?: Test) { +export function getTestNames(test?: Test) { if (!test) { return {} } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index ac67b8ba7bf4..af0dd7516cd1 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -698,6 +698,10 @@ export const cliOptionsConfig: VitestCLIOptions = { exclude: null, }, }, + // TODO: what to name? + attest: { + description: 'Enable runtime type assertions (default: `false`)', + }, project: { description: 'The name of the project to run if you are using Vitest workspace feature. This can be repeated for multiple projects: `--project=1 --project=2`. You can also filter projects using wildcards like `--project=packages*`, and exclude projects with `--project=!pattern`.', diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index 234e67a3be34..5c48a5905438 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -164,5 +164,6 @@ export function serializeConfig( benchmark: config.benchmark && { includeSamples: config.benchmark.includeSamples, }, + attest: config.attest, } } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 4d54e6b763df..01d003a17c2e 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -206,6 +206,10 @@ export class Vitest { this.configOverride.testNamePattern = this.config.testNamePattern } + for (const project of projects) { + await project._initAttest() + } + await Promise.all(this._onSetServer.map(fn => fn())) } diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index e25c03992735..75342b751a19 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -27,6 +27,7 @@ import { isAbsolute, join, relative } from 'pathe' import { ViteNodeRunner } from 'vite-node/client' import { ViteNodeServer } from 'vite-node/server' import { setup } from '../api/setup' +import { attestGlobalSetup } from '../integrations/attest/node' import { isBrowserEnabled, resolveConfig } from './config/resolveConfig' import { serializeConfig } from './config/serializeConfig' import { loadGlobalSetupFiles } from './globalSetup' @@ -218,6 +219,12 @@ export class TestProject { this.runner, this.config.globalSetup, ) + if (this.config.attest) { + this._globalSetups.push({ + file: 'attest', + setup: attestGlobalSetup, + }) + } for (const globalSetupFile of this._globalSetups) { const teardown = await globalSetupFile.setup?.(this) @@ -623,6 +630,18 @@ export class TestProject { } return project } + + /** @internal */ + async _initAttest(): Promise { + if (this.config.attest) { + // TODO: inject custom setupFiles and globalSetupFiles here? + // TODO: also ensure 'typescript'? + await this.vitest.packageInstaller.ensureInstalled( + '@ark/attest', + this.config.root, + ) + } + } } export { diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts index 3dcc8bef9e53..00cc32a08a74 100644 --- a/packages/vitest/src/node/types/config.ts +++ b/packages/vitest/src/node/types/config.ts @@ -691,6 +691,8 @@ export interface InlineConfig { */ typecheck?: Partial + attest?: boolean + /** * The number of milliseconds after which a test is considered slow and reported as such in the results. * diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index ef9b8d0b4f56..fddd8cfa69f4 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -133,6 +133,7 @@ export interface SerializedConfig { benchmark?: { includeSamples: boolean } + attest: boolean } export interface SerializedCoverageConfig { diff --git a/packages/vitest/src/runtime/setup-common.ts b/packages/vitest/src/runtime/setup-common.ts index 033816637cbd..b9f4f596a5db 100644 --- a/packages/vitest/src/runtime/setup-common.ts +++ b/packages/vitest/src/runtime/setup-common.ts @@ -4,6 +4,7 @@ import type { SerializedConfig } from './config' import type { VitestExecutor } from './execute' import { addSerializer } from '@vitest/snapshot' import { setSafeTimers } from '@vitest/utils' +import { attestSetup } from '../integrations/attest/runtime' import { resetRunOnceCounter } from '../integrations/run-once' let globalSetup = false @@ -11,6 +12,7 @@ export async function setupCommonEnv(config: SerializedConfig) { resetRunOnceCounter() setupDefines(config.defines) setupEnv(config.env) + await attestSetup(config) if (globalSetup) { return diff --git a/packages/vitest/src/types/global.ts b/packages/vitest/src/types/global.ts index c9ba65420ac3..afabf4ba4479 100644 --- a/packages/vitest/src/types/global.ts +++ b/packages/vitest/src/types/global.ts @@ -64,6 +64,12 @@ declare module '@vitest/expect' { matchSnapshot: SnapshotMatcher toMatchSnapshot: SnapshotMatcher toMatchInlineSnapshot: InlineSnapshotMatcher + toMatchTypeSnapshot: SnapshotMatcher + toMatchTypeInlineSnapshot: InlineSnapshotMatcher + toMatchTypeErrorSnapshot: SnapshotMatcher + toMatchTypeErrorInlineSnapshot: InlineSnapshotMatcher + toMatchTypeCompletionSnapshot: SnapshotMatcher + toMatchTypeCompletionInlineSnapshot: InlineSnapshotMatcher /** * Checks that an error thrown by a function matches a previously recorded snapshot. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46ee80d3241f..70bee0d413c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -914,6 +914,9 @@ importers: '@antfu/install-pkg': specifier: ^0.4.1 version: 0.4.1 + '@ark/attest': + specifier: ^0.28.0 + version: 0.28.0(typescript@5.6.3) '@edge-runtime/vm': specifier: ^4.0.4 version: 4.0.4 @@ -1037,6 +1040,12 @@ importers: specifier: workspace:* version: link:../runner + test/attest: + devDependencies: + '@ark/attest': + specifier: ^0.28.0 + version: 0.28.0(typescript@5.6.3) + test/benchmark: devDependencies: vitest: @@ -1578,6 +1587,21 @@ packages: peerDependencies: ajv: '>=8' + '@ark/attest@0.28.0': + resolution: {integrity: sha512-jEt8xfuMsL0L/nKuj4Jg0PswDrkOnyt4BjV76vJBQ+VEdQN3K1+18Yt5fzDGMY1RTZI6u6YAXJWBG60RHYhrxA==} + hasBin: true + peerDependencies: + typescript: '*' + + '@ark/fs@0.24.0': + resolution: {integrity: sha512-I9zI/qja1+K7RZW67CLjZRMrhZQmL9O4aFeIaoWH0BNkUn3baHDAHdM/2qcxgQojzmOkxx/ZsHBye+heq+ueLg==} + + '@ark/schema@0.24.0': + resolution: {integrity: sha512-L9aHo485uunP8WhQHH1ofd/DiwyAOo2WS3FMF8AebmIVMjqHOKCXnNBUV1rBnzVZXaVslPlTcgAKjCDmCGoHZg==} + + '@ark/util@0.24.0': + resolution: {integrity: sha512-YfXWkSinlhKdzoXAYxGVPvv2WMHzSN2Rrw5+Aoj79ksj8aPQdQagsMdnDTobaaynnlLxWz46hP/7JmVTo5QQ/A==} + '@babel/code-frame@7.24.2': resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} engines: {node: '>=6.9.0'} @@ -3129,6 +3153,11 @@ packages: '@polka/url@1.0.0-next.24': resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==} + '@prettier/sync@0.5.2': + resolution: {integrity: sha512-Yb569su456XNx5BsH/Vyem7xD6g/y9iLmLUzRKM1a/dhU/D7HqqvkAG72znulXlMXztbV0iiu9O5AL8K98TzZQ==} + peerDependencies: + prettier: '*' + '@promptbook/utils@0.63.4': resolution: {integrity: sha512-ME3I9Twxu/d7hpnGTkNYMUyIY8IAwY5Mg86i4xpD1WSZKfYMTNQomvkyk2Fi33vZDu8NDwb6Quyd0zJ0T3xo9w==} @@ -3868,6 +3897,10 @@ packages: resolution: {integrity: sha512-0/TdC3aeRAsW7MDvYRwEc1Uwm0TIBfzjPFgg60UU2Haj5qsCs9cc3zNgY71edqE3LbWfF/WoZQd3lJoDXFQpag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/analyze-trace@0.10.1': + resolution: {integrity: sha512-RnlSOPh14QbopGCApgkSx5UBgGda5MX1cHqp2fsqfiDyCwGL/m1jaeB9fzu7didVS81LQqGZZuxFBcg8YU8EVw==} + hasBin: true + '@typescript/vfs@1.6.0': resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==} peerDependencies: @@ -4428,6 +4461,9 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arktype@2.0.0-rc.24: + resolution: {integrity: sha512-uZB2XXDMzkM613MqhDMjB/y4RPSqS55W9Xn+GoVLgdat/Dr6MC/E90YqFsDCon9zusl3NB/A59MzXeR6PcTo4A==} + array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} @@ -4826,6 +4862,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -5733,6 +5772,10 @@ packages: resolution: {integrity: sha512-yKHlle2YGxZE842MERVIplWwNH5VYmqqcPFgtnlU//K8gxuFFXu0pwd/CrfXTumFpeEiufsP7+opT/bPJa1yVw==} engines: {node: ^18.19.0 || >=20.5.0} + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -6790,10 +6833,19 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + jsonpointer@5.0.1: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jsonstream-next@3.0.0: + resolution: {integrity: sha512-aAi6oPhdt7BKyQn1SrIIGZBt0ukKuOUE1qV6kJ3GgioSOYzsRc8z9Hfr1BVmacA/jLe9nARfmgMGgn68BqIAgg==} + engines: {node: '>=10'} + hasBin: true + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} @@ -6973,6 +7025,9 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + make-synchronized@0.2.9: + resolution: {integrity: sha512-4wczOs8SLuEdpEvp3vGo83wh8rjJ78UsIk7DIX5fxdfmfMJGog4bQzxfvOwq7Q3yCHLC4jp1urPHIxRS/A93gA==} + mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} @@ -7724,6 +7779,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + pretty-bytes@5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -8392,6 +8452,9 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + split2@3.2.2: + resolution: {integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -8667,6 +8730,9 @@ packages: thread-stream@2.1.0: resolution: {integrity: sha512-5+Pf2Ya31CsZyIPYYkhINzdTZ3guL+jHq7D8lkBybgGcSQIKDbid3NJku3SpCKeE/gACWAccDA/rH2B6doC5aA==} + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -8778,6 +8844,10 @@ packages: traverse@0.3.9: resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + treeify@1.1.0: + resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} + engines: {node: '>=0.6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9599,10 +9669,18 @@ packages: engines: {node: '>= 14'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + yargs@17.7.1: resolution: {integrity: sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==} engines: {node: '>=12'} @@ -9831,6 +9909,27 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@ark/attest@0.28.0(typescript@5.6.3)': + dependencies: + '@ark/fs': 0.24.0 + '@ark/util': 0.24.0 + '@prettier/sync': 0.5.2(prettier@3.3.3) + '@typescript/analyze-trace': 0.10.1 + '@typescript/vfs': 1.6.0(typescript@5.6.3) + arktype: 2.0.0-rc.24 + prettier: 3.3.3 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@ark/fs@0.24.0': {} + + '@ark/schema@0.24.0': + dependencies: + '@ark/util': 0.24.0 + + '@ark/util@0.24.0': {} + '@babel/code-frame@7.24.2': dependencies: '@babel/highlight': 7.24.7 @@ -11409,6 +11508,11 @@ snapshots: '@polka/url@1.0.0-next.24': {} + '@prettier/sync@0.5.2(prettier@3.3.3)': + dependencies: + make-synchronized: 0.2.9 + prettier: 3.3.3 + '@promptbook/utils@0.63.4': dependencies: spacetrim: 0.11.39 @@ -12261,6 +12365,17 @@ snapshots: '@typescript-eslint/types': 8.8.1 eslint-visitor-keys: 3.4.3 + '@typescript/analyze-trace@0.10.1': + dependencies: + chalk: 4.1.2 + exit: 0.1.2 + jsonparse: 1.3.1 + jsonstream-next: 3.0.0 + p-limit: 3.1.0 + split2: 3.2.2 + treeify: 1.1.0 + yargs: 16.2.0 + '@typescript/vfs@1.6.0(typescript@5.6.3)': dependencies: debug: 4.3.7 @@ -13004,6 +13119,11 @@ snapshots: dependencies: dequal: 2.0.3 + arktype@2.0.0-rc.24: + dependencies: + '@ark/schema': 0.24.0 + '@ark/util': 0.24.0 + array-buffer-byte-length@1.0.0: dependencies: call-bind: 1.0.5 @@ -13486,6 +13606,12 @@ snapshots: cli-width@4.1.0: {} + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -14617,6 +14743,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.1 + exit@0.1.2: {} + expand-template@2.0.3: {} expect-type@1.1.0: {} @@ -15854,8 +15982,15 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonparse@1.3.1: {} + jsonpointer@5.0.1: {} + jsonstream-next@3.0.0: + dependencies: + jsonparse: 1.3.1 + through2: 4.0.2 + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -16045,6 +16180,8 @@ snapshots: dependencies: semver: 7.6.3 + make-synchronized@0.2.9: {} + mark.js@8.11.1: {} markdown-table@3.0.3: {} @@ -16948,6 +17085,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.3.3: {} + pretty-bytes@5.6.0: {} pretty-bytes@6.1.1: {} @@ -17749,6 +17888,10 @@ snapshots: speakingurl@14.0.1: {} + split2@3.2.2: + dependencies: + readable-stream: 3.6.0 + split2@4.2.0: {} splitpanes@3.1.5: {} @@ -18064,6 +18207,10 @@ snapshots: dependencies: real-require: 0.2.0 + through2@4.0.2: + dependencies: + readable-stream: 3.6.0 + through@2.3.8: {} tiny-glob@0.2.9: @@ -18156,6 +18303,8 @@ snapshots: traverse@0.3.9: {} + treeify@1.1.0: {} + trim-lines@3.0.1: {} ts-api-utils@1.3.0(typescript@5.6.3): @@ -19193,8 +19342,20 @@ snapshots: yaml@2.4.5: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.1: dependencies: cliui: 8.0.1 diff --git a/test/attest/fixtures/test/__snapshots__/snapshot.test.ts.snap b/test/attest/fixtures/test/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 000000000000..9d936293f378 --- /dev/null +++ b/test/attest/fixtures/test/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`completions 2`] = ` +{ + "to": [ + "toExponential", + "toFixed", + "toLocaleString", + "toPrecision", + "toString", + ], +} +`; + +exports[`file snapshot 1`] = `number`; + +exports[`file snapshot 2`] = `(precision?: number | undefined) => string`; + +exports[`type error 2`] = `The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.`; diff --git a/test/attest/fixtures/test/attest.test.ts b/test/attest/fixtures/test/attest.test.ts new file mode 100644 index 000000000000..d91b205c1cf8 --- /dev/null +++ b/test/attest/fixtures/test/attest.test.ts @@ -0,0 +1,6 @@ +import { test } from 'vitest' +import { attest } from '@ark/attest' + +test('basic', () => { + attest(1 + 2) +}) diff --git a/test/attest/fixtures/test/snapshot.test.ts b/test/attest/fixtures/test/snapshot.test.ts new file mode 100644 index 000000000000..ebb6aa9ea77a --- /dev/null +++ b/test/attest/fixtures/test/snapshot.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'vitest' + +test('inline snapshot', () => { + expect(1 + 2).toMatchTypeInlineSnapshot(`number`) + expect((1 + 2).toFixed).toMatchTypeInlineSnapshot( + `(fractionDigits?: number | undefined) => string`, + ) +}) + +test('file snapshot', () => { + expect(1 + 2).toMatchTypeSnapshot() + expect((1 + 2).toPrecision).toMatchTypeSnapshot() +}) + +test('type error', () => { + expect(() => + // @ts-expect-error test + '1' / 2, + ).toMatchTypeErrorInlineSnapshot( + `The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.`, + ) + + expect(() => + // @ts-expect-error test + '1' / 2, + ).toMatchTypeErrorSnapshot() +}) + +test('completions', () => { + expect( + () => + // @ts-expect-error test + // eslint-disable-next-line dot-notation + (1 + 2)['to'], + ).toMatchTypeCompletionInlineSnapshot(` + { + "to": [ + "toExponential", + "toFixed", + "toLocaleString", + "toPrecision", + "toString", + ], + } + `) + + expect( + () => + // @ts-expect-error test + // eslint-disable-next-line dot-notation + (1 + 2)['to'], + ).toMatchTypeCompletionSnapshot() +}) diff --git a/test/attest/fixtures/tsconfig.json b/test/attest/fixtures/tsconfig.json new file mode 100644 index 000000000000..030c885228b5 --- /dev/null +++ b/test/attest/fixtures/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "test", "*.ts"] +} diff --git a/test/attest/fixtures/vite.config.ts b/test/attest/fixtures/vite.config.ts new file mode 100644 index 000000000000..abed6b2116e1 --- /dev/null +++ b/test/attest/fixtures/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({}) diff --git a/test/attest/package.json b/test/attest/package.json new file mode 100644 index 000000000000..bd3925847635 --- /dev/null +++ b/test/attest/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vitest/test-attest", + "type": "module", + "private": true, + "scripts": { + "test": "vitest", + "test-fixtures": "cd fixtures && vitest --attest", + "test-fixtures-skip-types": "cd fixtures && vitest" + }, + "devDependencies": { + "@ark/attest": "^0.28.0" + } +} diff --git a/test/attest/test/basic.test.ts b/test/attest/test/basic.test.ts new file mode 100644 index 000000000000..e354e6641469 --- /dev/null +++ b/test/attest/test/basic.test.ts @@ -0,0 +1,35 @@ +import type { SpawnOptions } from 'node:child_process' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { editFile, runVitestCli } from '../../test-utils' + +it('skip types', { timeout: 20_000 }, async () => { + // for now, use cli since cwd is essential for attest + const dir = join(import.meta.dirname, '../fixtures') + const options: SpawnOptions = { cwd: dir } + + // [ATTEST] pass with correct snapshots + let result = await runVitestCli({ nodeOptions: options }, 'run', '--attest') + expect(result.stderr).toBe('') + expect(result.stdout).toContain('Waiting for TypeScript') + expect(result.stdout).toContain('Test Files 2 passed') + + // [NO ATTEST] pass with wrong snapshot + editFile( + join(dir, 'test/__snapshots__/snapshot.test.ts.snap'), + s => s.replace('exports[`file snapshot 1`] = `number`', ''), + ) + result = await runVitestCli({ nodeOptions: options }, 'run', '--update') + expect(result.stderr).toBe('') + expect(result.stdout).not.toContain('Waiting for TypeScript') + expect(result.stdout).not.toContain('Snapshots 1 written') + expect(result.stdout).not.toContain('obsolete') + expect(result.stdout).toContain('Test Files 2 passed') + + // [ATTEST] update snapshot + result = await runVitestCli({ nodeOptions: options }, 'run', '--attest', '--update') + expect(result.stderr).toBe('') + expect(result.stdout).toContain('Waiting for TypeScript') + expect(result.stdout).toContain('Snapshots 1 written') + expect(result.stdout).toContain('Test Files 2 passed') +}) diff --git a/test/attest/vite.config.ts b/test/attest/vite.config.ts new file mode 100644 index 000000000000..617561ce45f2 --- /dev/null +++ b/test/attest/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: ['**/fixtures/**'], + }, +})