Skip to content

Commit 55c466f

Browse files
committed
feat: revamp runtime to be based on iframe and vitest
1 parent bc63fec commit 55c466f

File tree

87 files changed

+6785
-213
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+6785
-213
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
node_modules/
22
dist/
33
histoire-dist/
4-
.histoire/dist/
4+
.histoire
55
.idea
66
**/cypress/screenshots/
77
**/cypress/videos/

docs/reference/client.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,20 @@ import { toggleDark } from 'histoire/client'
4747

4848
toggleDark(true)
4949
```
50+
51+
## `onTest`
52+
53+
Registers story-scoped Vitest cases for the current variant.
54+
55+
```ts
56+
import { onTest } from 'histoire/client'
57+
import { describe, expect, it } from 'vitest'
58+
59+
onTest(({ canvas }) => {
60+
describe('my component', () => {
61+
it('renders', () => {
62+
expect(canvas.textContent).toContain('Hello')
63+
})
64+
})
65+
})
66+
```

examples/vue3/cypress/e2e/controls.cy.js

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,46 @@ describe('Controls', () => {
66
.should('not.be.empty')
77
.then(cy.wrap)
88

9+
const getControls = () => cy.get('[data-test-id="story-controls"]')
10+
const getControl = label => getControls().contains('label', new RegExp(`^${label}$`))
11+
912
beforeEach(() => {
1013
cy.visit('/')
1114
cy.get('[data-test-id="story-list-item"]').contains('Controls').click()
15+
cy.get('[data-test-id="story-side-panel"]').should('be.visible')
16+
cy.get('[data-test-id="story-side-panel"]').contains('Loading...').should('not.exist')
1217
})
1318

14-
it('HstText', () => {
19+
it('updates text state', () => {
1520
getIframeBody().find('.state-output').contains('"text": "Hello"')
16-
cy.get('[data-test-id="story-controls"]').contains('HstText').clear().type('Foo')
21+
getControl('text').find('input').clear().type('Foo')
1722
getIframeBody().find('.state-output').contains('"text": "Foo"')
1823
})
1924

20-
it('HstCheckbox', () => {
25+
it('updates checkbox state', () => {
2126
getIframeBody().find('.state-output').contains('"checkbox": false')
22-
cy.get('[data-test-id="story-controls"]').contains('HstCheckbox').click()
27+
getControl('checkbox').click()
2328
getIframeBody().find('.state-output').contains('"checkbox": true')
24-
cy.get('[data-test-id="story-controls"]').contains('HstCheckbox').click()
29+
getControl('checkbox').click()
2530
getIframeBody().find('.state-output').contains('"checkbox": false')
2631
})
2732

28-
it('HstNumber', () => {
33+
it('updates numeric state', () => {
2934
getIframeBody().find('.state-output').contains('"number": 20')
30-
cy.get('[data-test-id="story-controls"] input[type="number"]').clear().type('42')
31-
getIframeBody().find('.state-output').contains('"number": 42')
35+
getControl('number').find('input').clear()
36+
getControl('number').find('input').type('42')
37+
getIframeBody().find('.state-output').contains('"number": "42"')
3238
})
3339

34-
it('HstTextarea', () => {
40+
it('updates long text state', () => {
3541
getIframeBody().find('.state-output').contains('"longText": "Longer text..."')
36-
cy.get('[data-test-id="story-controls"] textarea').clear().type('Meow meow meow')
42+
getControl('longText').find('input').clear().type('Meow meow meow')
3743
getIframeBody().find('.state-output').contains('"longText": "Meow meow meow"')
3844
})
3945

40-
it('HstColorSelect', () => {
46+
it('updates color state', () => {
4147
getIframeBody().find('.state-output').contains('"colorselect": "#000000"')
42-
cy.get('[data-test-id="story-controls"]').contains('HstColorSelect').clear().type('#ffffff')
48+
getControl('colorselect').find('input').clear().type('#ffffff')
4349
getIframeBody().find('.state-output').contains('"colorselect": "#ffffff"')
4450
})
4551
})
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/// <reference types="cypress" />
2+
3+
describe('Preview tests panel', () => {
4+
const storyPath = 'src/components/VitestMocking.story.vue'
5+
const hmrInsertionMarker = ' // HMR_TEST_INSERTION_POINT'
6+
let originalStorySource = ''
7+
8+
const getIframeBody = () => cy.get('iframe[data-test-id="preview-iframe"]')
9+
.its('0.contentDocument.body')
10+
.should('not.be.empty')
11+
.then(cy.wrap)
12+
13+
const assertMockedGreeting = () => {
14+
getIframeBody().find('iframe').should('have.length', 0)
15+
getIframeBody().contains('Mocked by Vitest for Vitest browser mode', {
16+
timeout: 20000,
17+
})
18+
getIframeBody().should('not.contain', 'Failed to resolve vitest:mocks:resolveMock in time')
19+
}
20+
21+
function openVitestStory() {
22+
cy.get('[data-test-id="story-list-item"]').contains('Vitest Mocking').click()
23+
cy.location('search').should('include', 'variantId=src-components-vitestmocking-story-vue-0')
24+
}
25+
26+
function openTestsPanel() {
27+
cy.get('[data-test-id="story-tests-tab"]:visible').click()
28+
cy.get('[data-test-id="story-side-panel"]').should('be.visible')
29+
cy.get('[data-test-id="story-side-panel"]').contains('Loading...').should('not.exist')
30+
}
31+
32+
function assertCollectedDefinitions(count) {
33+
cy.get('iframe[data-test-id="preview-iframe"]')
34+
.its('0.contentWindow.__HST_TEST_DEFINITIONS__')
35+
.should((definitions) => {
36+
expect(Array.isArray(definitions)).to.equal(true)
37+
expect(definitions).to.have.length(count)
38+
})
39+
}
40+
41+
function assertTestsTabCount(count) {
42+
cy.get('[data-test-id="story-tests-tab-count"]:visible')
43+
.should('have.text', `${count}`)
44+
}
45+
46+
beforeEach(() => {
47+
cy.readFile(storyPath).then((source) => {
48+
originalStorySource = source
49+
})
50+
})
51+
52+
afterEach(() => {
53+
if (!originalStorySource) {
54+
return
55+
}
56+
57+
cy.writeFile(storyPath, originalStorySource)
58+
})
59+
60+
it('collects story tests registered from a single onTest callback', () => {
61+
cy.viewport(1600, 1000)
62+
cy.visit('/')
63+
64+
openVitestStory()
65+
assertCollectedDefinitions(3)
66+
assertTestsTabCount(3)
67+
assertMockedGreeting()
68+
69+
openTestsPanel()
70+
cy.contains('button', 'Run tests').should('be.visible')
71+
cy.get('[data-test-id="story-test-row"]').should('have.length', 3)
72+
cy.get('[data-test-id="story-test-row"]').each(($row) => {
73+
cy.wrap($row).contains('Not run')
74+
})
75+
76+
cy.contains('button', 'Run tests').click()
77+
cy.get('[data-test-id="story-test-row"]').should('have.length', 3)
78+
cy.contains('[data-test-id="story-test-row"]', 'renders the mocked dependency output').contains('passed')
79+
cy.contains('[data-test-id="story-test-row"]', 'tracks calls through the mocked module function').contains('passed')
80+
cy.contains('[data-test-id="story-test-row"]', 'fails').as('failedRow')
81+
cy.get('@failedRow').contains('failed')
82+
cy.get('@failedRow').contains('This test is expected to fail')
83+
})
84+
85+
it('refreshes mocked story tests after hot updates', () => {
86+
cy.viewport(1600, 1000)
87+
cy.visit('/')
88+
89+
openVitestStory()
90+
openTestsPanel()
91+
assertCollectedDefinitions(3)
92+
assertTestsTabCount(3)
93+
cy.get('[data-test-id="story-test-row"]').should('have.length', 3)
94+
assertMockedGreeting()
95+
96+
cy.then(() => {
97+
const updatedStorySource = originalStorySource.replace(hmrInsertionMarker, ` it('updates the tests panel after hot reload', () => {
98+
expect(canvas.textContent).toContain('Mocked by Vitest for Vitest browser mode')
99+
})
100+
${hmrInsertionMarker}`)
101+
102+
expect(updatedStorySource).not.to.equal(originalStorySource)
103+
cy.writeFile(storyPath, updatedStorySource)
104+
})
105+
106+
assertCollectedDefinitions(4)
107+
assertTestsTabCount(4)
108+
cy.get('[data-test-id="story-test-row"]', {
109+
timeout: 20000,
110+
}).should('have.length', 4)
111+
assertMockedGreeting()
112+
113+
cy.writeFile(storyPath, originalStorySource)
114+
115+
assertCollectedDefinitions(3)
116+
assertTestsTabCount(3)
117+
cy.get('[data-test-id="story-test-row"]', {
118+
timeout: 20000,
119+
}).should('have.length', 3)
120+
assertMockedGreeting()
121+
})
122+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/// <reference types="cypress" />
2+
3+
describe('Vitest story runtime', () => {
4+
const getIframeBody = () => cy.get('iframe[data-test-id="preview-iframe"]')
5+
.its('0.contentDocument.body')
6+
.should('not.be.empty')
7+
.then(cy.wrap)
8+
9+
const assertMockedGreeting = () => {
10+
getIframeBody().find('iframe').should('have.length', 0)
11+
getIframeBody().contains('Mocked by Vitest for Vitest browser mode', {
12+
timeout: 10000,
13+
})
14+
}
15+
16+
function openVitestStory() {
17+
cy.get('[data-test-id="story-list-item"]').contains('Vitest Mocking').click()
18+
cy.location('search').should('include', 'variantId=src-components-vitestmocking-story-vue-0')
19+
20+
cy.get('body').then(($body) => {
21+
if ($body.find('[data-test-id="story-variant-list-item"]').length) {
22+
cy.contains('[data-test-id="story-variant-list-item"]', 'mocked module in story setup').click()
23+
}
24+
})
25+
}
26+
27+
it('reopens the story without losing the manual Vitest mock state', () => {
28+
cy.viewport(1600, 1000)
29+
cy.visit('/')
30+
openVitestStory()
31+
32+
assertMockedGreeting()
33+
34+
cy.get('[data-test-id="story-list-item"]').contains('BaseButton').click()
35+
cy.location('pathname').should('include', '/story/src-components-basebutton-story-vue')
36+
37+
openVitestStory()
38+
assertMockedGreeting()
39+
})
40+
41+
it('shows the tests pane without getting stuck in loading', () => {
42+
cy.viewport(1600, 1000)
43+
cy.visit('/')
44+
openVitestStory()
45+
46+
cy.contains('button, a', 'Tests').click()
47+
cy.get('[data-test-id="story-side-panel"]').should('be.visible')
48+
cy.get('[data-test-id="story-side-panel"]').contains('Loading...').should('not.exist')
49+
cy.contains('button', 'Run tests').should('be.visible')
50+
})
51+
})

examples/vue3/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"nodemon": "^3.1.7",
3333
"sass": "^1.82.0",
3434
"start-server-and-test": "^2.0.8",
35-
"vite": "catalog:"
35+
"vite": "catalog:",
36+
"vitest": "4.0.16"
3637
}
3738
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<script setup lang="ts">
2+
import { computed } from 'vue'
3+
import { getGreeting } from './vitest-mocking-greeting'
4+
5+
const props = withDefaults(defineProps<{
6+
name?: string
7+
}>(), {
8+
name: 'Vitest browser mode',
9+
})
10+
11+
const greeting = computed(() => getGreeting(props.name))
12+
</script>
13+
14+
<template>
15+
<div class="htw-inline-flex htw-flex-col htw-gap-2 htw-p-4">
16+
<div class="htw-text-xs htw-font-semibold htw-uppercase htw-tracking-[0.18em]">
17+
Vitest Mock
18+
</div>
19+
<div class="htw-text-lg htw-font-medium">
20+
{{ greeting }}
21+
</div>
22+
</div>
23+
</template>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<script setup lang="ts">
2+
import { onTest } from 'histoire/client'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { getGreeting } from './vitest-mocking-greeting'
5+
import VitestMockedGreeting from './VitestMockedGreeting.vue'
6+
7+
vi.mock('./vitest-mocking-greeting', () => ({
8+
getGreeting: vi.fn((name: string) => `Mocked by Vitest for ${name}`),
9+
}))
10+
11+
const mockedGetGreeting = vi.mocked(getGreeting)
12+
13+
onTest(({ canvas }) => {
14+
describe('mocked module in story setup', () => {
15+
it('renders the mocked dependency output', () => {
16+
expect(canvas.textContent).toContain('Mocked by Vitest for Vitest browser mode')
17+
})
18+
19+
it('tracks calls through the mocked module function', () => {
20+
expect(vi.isMockFunction(getGreeting)).toBe(true)
21+
expect(mockedGetGreeting).toHaveBeenCalledWith('Vitest browser mode')
22+
})
23+
// HMR_TEST_INSERTION_POINT
24+
it('fails', () => {
25+
expect(canvas.textContent).toContain('This test is expected to fail')
26+
})
27+
})
28+
})
29+
</script>
30+
31+
<template>
32+
<Story title="Vitest Mocking">
33+
<Variant title="mocked module in story setup">
34+
<VitestMockedGreeting />
35+
</Variant>
36+
</Story>
37+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function getGreeting(name: string) {
2+
return `Original greeting for ${name}`
3+
}

packages/histoire-app/src/app/App.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const commandStore = useCommandStore()
9696

9797
<template>
9898
<div
99-
v-if="storyStore.currentStory"
99+
v-if="storyStore.currentStory && !storyStore.currentStory.file?.hasVitestMocks"
100100
class="histoire-app htw-hidden"
101101
>
102102
<GenericMountStory

0 commit comments

Comments
 (0)