Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: attest integration #6963

Open
wants to merge 51 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
8b3be5e
chore: notes
hi-ogawa Nov 4, 2024
bea78b4
feat: skipSnapshot
hi-ogawa Nov 4, 2024
64f01b0
test: more examples
hi-ogawa Nov 5, 2024
9c90cd0
chore: cleanup
hi-ogawa Nov 5, 2024
4c49f5e
test: test skipTypes
hi-ogawa Nov 5, 2024
7a1bf59
Merge branch 'main' into feat-skip-snapshot-serialize
hi-ogawa Nov 7, 2024
f0bd76d
test: tweak
hi-ogawa Nov 7, 2024
ad2f871
test: debug
hi-ogawa Nov 7, 2024
a747c16
test: debug
hi-ogawa Nov 7, 2024
3e3398e
ci: debug
hi-ogawa Nov 7, 2024
bce4c19
ci: debug
hi-ogawa Nov 7, 2024
d893ba6
ci: debug
hi-ogawa Nov 7, 2024
87239e3
ci: debug
hi-ogawa Nov 7, 2024
e77e0db
ci: debug
hi-ogawa Nov 7, 2024
c82948a
ci: debug
hi-ogawa Nov 7, 2024
ab84ba4
Merge branch 'main' into feat-skip-snapshot-serialize
hi-ogawa Nov 9, 2024
13532e5
refactor: move code
hi-ogawa Nov 9, 2024
f508e5d
chore: workaround with reporter onWatcherRerun for now
hi-ogawa Nov 9, 2024
beddaf5
test: compare with expect-type
hi-ogawa Nov 9, 2024
892de16
Merge branch 'main' into feat-skip-snapshot-serialize
hi-ogawa Nov 15, 2024
f6f0bf5
refactor: use onTestsRerun
hi-ogawa Nov 15, 2024
303a3bc
Merge branch 'main' into feat-skip-snapshot-serialize
hi-ogawa Nov 23, 2024
e02c118
wip: update attest + use unwrap
hi-ogawa Nov 23, 2024
fde8997
wip: update attest + use unwrap
hi-ogawa Nov 23, 2024
15fe09a
Merge branch 'main' into feat-skip-snapshot-serialize
hi-ogawa Nov 26, 2024
d3208ca
Merge remote-tracking branch 'origin/feat-skip-snapshot-serialize' in…
hi-ogawa Nov 26, 2024
c89ddcf
wip: add peer dep
hi-ogawa Nov 26, 2024
9faab66
wip: inline
hi-ogawa Nov 26, 2024
f8ab70b
test: cleanup
hi-ogawa Nov 26, 2024
a03b4ef
chore: remove skipSnapshot
hi-ogawa Nov 26, 2024
9131fb3
fix: introduce __vitest_expect_stack
hi-ogawa Nov 26, 2024
56d2f16
wip: add config
hi-ogawa Nov 26, 2024
1ea7439
chore: ensure installed @ark/attest
hi-ogawa Nov 26, 2024
0954423
wip: support normal attest usage
hi-ogawa Nov 26, 2024
b008122
refactor: minor
hi-ogawa Nov 26, 2024
76ab6bb
wip: toMatchTypeErrorSnapshot
hi-ogawa Nov 26, 2024
41ca919
wip: toMatchTypeCompletionInlineSnapshot
hi-ogawa Nov 26, 2024
96c42cf
chore: lint
hi-ogawa Nov 26, 2024
1425192
chore: cleanup
hi-ogawa Nov 26, 2024
2ee32da
Merge branch 'main' into feat-attest-integration
hi-ogawa Nov 26, 2024
0f75be9
Merge branch 'main' into feat-attest-integration
hi-ogawa Nov 26, 2024
09cface
wip: tweak ensureInstalled
hi-ogawa Nov 27, 2024
f9282f7
wip: project._initAttest
hi-ogawa Nov 27, 2024
e1fea76
chore: comment
hi-ogawa Nov 27, 2024
e485ea0
fix: fix ensureInstalled version
hi-ogawa Nov 27, 2024
76df825
chore: cleanup
hi-ogawa Nov 27, 2024
6999339
refactor: remove PrettyFormatSkipSnapshotError hack
hi-ogawa Nov 27, 2024
9c5f652
chore: rename
hi-ogawa Nov 27, 2024
4e2cd79
refactor: use writeAssertionData
hi-ogawa Nov 27, 2024
c0b565e
chore: comment
hi-ogawa Nov 27, 2024
7d0bfd5
refactor: cleanup inline snapshot regex
hi-ogawa Nov 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ test/**/__screenshots__/**/*
test/browser/fixtures/update-snapshot/basic.test.ts
test/cli/fixtures/browser-multiple/basic-*
.vitest-reports
.attest
3 changes: 3 additions & 0 deletions packages/snapshot/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ interface AssertOptions {
error?: Error
errorMessage?: string
rawSnapshot?: RawSnapshotInfo
skip?: boolean
}

export interface SnapshotClientOptions {
Expand Down Expand Up @@ -102,6 +103,7 @@ export class SnapshotClient {
error,
errorMessage,
rawSnapshot,
skip,
} = options
let { received } = options

Expand Down Expand Up @@ -148,6 +150,7 @@ export class SnapshotClient {
error,
inlineSnapshot,
rawSnapshot,
skip,
})

if (!pass) {
Expand Down
36 changes: 17 additions & 19 deletions packages/snapshot/src/port/inlineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion packages/snapshot/src/port/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/snapshot/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface SnapshotMatchOptions {
isInline: boolean
error?: Error
rawSnapshot?: RawSnapshotInfo
skip?: boolean
}

export interface SnapshotResult {
Expand Down
5 changes: 5 additions & 0 deletions packages/vitest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -130,6 +131,9 @@
"jsdom": "*"
},
"peerDependenciesMeta": {
"@ark/attest": {
"optional": true
},
"@edge-runtime/vm": {
"optional": true
},
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions packages/vitest/src/integrations/attest/node.ts
Original file line number Diff line number Diff line change
@@ -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())
}
220 changes: 220 additions & 0 deletions packages/vitest/src/integrations/attest/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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'
}
}
}
Loading
Loading