import { beforeAll, describe, expect, it, test } from 'vitest' import { browserLogs, editFile, getBg, isBuild, page, untilBrowserLogAfter, untilUpdated, viteTestUrl } from '~utils' test('should render', async () => { expect(await page.textContent('.app')).toBe('1') expect(await page.textContent('.dep')).toBe('1') expect(await page.textContent('.nested')).toBe('1') }) if (!isBuild) { test('should connect', async () => { expect(browserLogs.length).toBe(2) expect(browserLogs.some((msg) => msg.match('connected'))).toBe(true) browserLogs.length = 0 }) test('self accept', async () => { const el = await page.$('.app') editFile('hmr.ts', (code) => code.replace('const foo = 1', 'const foo = 2')) await untilUpdated(() => el.textContent(), '2') expect(browserLogs).toMatchObject([ '>>> vite:beforeUpdate -- update', 'foo was: 1', '(self-accepting 1) foo is now: 2', '(self-accepting 2) foo is now: 2', '[vite] hot updated: /hmr.ts' ]) browserLogs.length = 0 editFile('hmr.ts', (code) => code.replace('const foo = 2', 'const foo = 3')) await untilUpdated(() => el.textContent(), '3') expect(browserLogs).toMatchObject([ '>>> vite:beforeUpdate -- update', 'foo was: 2', '(self-accepting 1) foo is now: 3', '(self-accepting 2) foo is now: 3', '[vite] hot updated: /hmr.ts' ]) browserLogs.length = 0 }) test('accept dep', async () => { const el = await page.$('.dep') editFile('hmrDep.js', (code) => code.replace('const foo = 1', 'const foo = 2') ) await untilUpdated(() => el.textContent(), '2') expect(browserLogs).toMatchObject([ '>>> vite:beforeUpdate -- update', '(dep) foo was: 1', '(dep) foo from dispose: 1', '(single dep) foo is now: 2', '(single dep) nested foo is now: 1', '(multi deps) foo is now: 2', '(multi deps) nested foo is now: 1', '[vite] hot updated: /hmrDep.js via /hmr.ts' ]) browserLogs.length = 0 editFile('hmrDep.js', (code) => code.replace('const foo = 2', 'const foo = 3') ) await untilUpdated(() => el.textContent(), '3') expect(browserLogs).toMatchObject([ '>>> vite:beforeUpdate -- update', '(dep) foo was: 2', '(dep) foo from dispose: 2', '(single dep) foo is now: 3', '(single dep) nested foo is now: 1', '(multi deps) foo is now: 3', '(multi deps) nested foo is now: 1', '[vite] hot updated: /hmrDep.js via /hmr.ts' ]) browserLogs.length = 0 }) test('nested dep propagation', async () => { const el = await page.$('.nested') editFile('hmrNestedDep.js', (code) => code.replace('const foo = 1', 'const foo = 2') ) await untilUpdated(() => el.textContent(), '2') expect(browserLogs).toMatchObject([ '>>> vite:beforeUpdate -- update', '(dep) foo was: 3', '(dep) foo from dispose: 3', '(single dep) foo is now: 3', '(single dep) nested foo is now: 2', '(multi deps) foo is now: 3', '(multi deps) nested foo is now: 2', '[vite] hot updated: /hmrDep.js via /hmr.ts' ]) browserLogs.length = 0 editFile('hmrNestedDep.js', (code) => code.replace('const foo = 2', 'const foo = 3') ) await untilUpdated(() => el.textContent(), '3') expect(browserLogs).toMatchObject([ '>>> vite:beforeUpdate -- update', '(dep) foo was: 3', '(dep) foo from dispose: 3', '(single dep) foo is now: 3', '(single dep) nested foo is now: 3', '(multi deps) foo is now: 3', '(multi deps) nested foo is now: 3', '[vite] hot updated: /hmrDep.js via /hmr.ts' ]) browserLogs.length = 0 }) test('plugin hmr handler + custom event', async () => { const el = await page.$('.custom') editFile('customFile.js', (code) => code.replace('custom', 'edited')) await untilUpdated(() => el.textContent(), 'edited') }) test('plugin client-server communication', async () => { const el = await page.$('.custom-communication') await untilUpdated(() => el.textContent(), '3') }) test('full-reload encodeURI path', async () => { await page.goto( viteTestUrl + '/unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html' ) const el = await page.$('#app') expect(await el.textContent()).toBe('title') editFile('unicode-path/中文-にほんご-한글-🌕🌖🌗/index.html', (code) => code.replace('title', 'title2') ) await page.waitForEvent('load') await untilUpdated( async () => (await page.$('#app')).textContent(), 'title2' ) }) test('CSS update preserves query params', async () => { await page.goto(viteTestUrl) editFile('global.css', (code) => code.replace('white', 'tomato')) const elprev = await page.$('.css-prev') const elpost = await page.$('.css-post') await untilUpdated(() => elprev.textContent(), 'param=required') await untilUpdated(() => elpost.textContent(), 'param=required') const textprev = await elprev.textContent() const textpost = await elpost.textContent() expect(textprev).not.toBe(textpost) expect(textprev).not.toMatch('direct') expect(textpost).not.toMatch('direct') }) test('it swaps out link tags', async () => { await page.goto(viteTestUrl) editFile('global.css', (code) => code.replace('white', 'tomato')) let el = await page.$('.link-tag-added') await untilUpdated(() => el.textContent(), 'yes') el = await page.$('.link-tag-removed') await untilUpdated(() => el.textContent(), 'yes') expect((await page.$$('link')).length).toBe(1) }) test('not loaded dynamic import', async () => { await page.goto(viteTestUrl + '/counter/index.html') let btn = await page.$('button') expect(await btn.textContent()).toBe('Counter 0') await btn.click() expect(await btn.textContent()).toBe('Counter 1') // Modifying `index.ts` triggers a page reload, as expected editFile('counter/index.ts', (code) => code) await page.waitForNavigation() btn = await page.$('button') expect(await btn.textContent()).toBe('Counter 0') await btn.click() expect(await btn.textContent()).toBe('Counter 1') // #7561 // `dep.ts` defines `import.module.hot.accept` and has not been loaded. // Therefore, modifying it has no effect (doesn't trigger a page reload). // (Note that, a dynamic import that is never loaded and that does not // define `accept.module.hot.accept` may wrongfully trigger a full page // reload, see discussion at #7561.) editFile('counter/dep.ts', (code) => code) try { await page.waitForNavigation({ timeout: 1000 }) } catch (err) { const errMsg = 'page.waitForNavigation: Timeout 1000ms exceeded.' expect(err.message.slice(0, errMsg.length)).toBe(errMsg) } btn = await page.$('button') expect(await btn.textContent()).toBe('Counter 1') }) // #2255 test('importing reloaded', async () => { await page.goto(viteTestUrl) const outputEle = await page.$('.importing-reloaded') const getOutput = () => { return outputEle.innerHTML() } await untilUpdated(getOutput, ['a.js: a0', 'b.js: b0,a0'].join('
')) editFile('importing-updated/a.js', (code) => code.replace("'a0'", "'a1'")) await untilUpdated( getOutput, ['a.js: a0', 'b.js: b0,a0', 'a.js: a1'].join('
') ) editFile('importing-updated/b.js', (code) => code.replace('`b0,${a}`', '`b1,${a}`') ) // note that "a.js: a1" should not happen twice after "b.js: b0,a0'" await untilUpdated( getOutput, ['a.js: a0', 'b.js: b0,a0', 'a.js: a1', 'b.js: b1,a1'].join('
') ) }) describe('acceptExports', () => { const HOT_UPDATED = /hot updated/ const CONNECTED = /connected/ const baseDir = 'accept-exports' describe('when all used exports are accepted', () => { const testDir = baseDir + '/main-accepted' const fileName = 'target.ts' const file = `${testDir}/${fileName}` const url = '/' + file let dep = 'dep0' beforeAll(async () => { await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<<<<< A0 B0 D0 ; ${dep}`) expect(logs).toContain('>>>>>> A0 D0') } ) }) it('the callback is called with the new version the module', async () => { const callbackFile = `${testDir}/callback.ts` const callbackUrl = '/' + callbackFile await untilBrowserLogAfter( () => { editFile(callbackFile, (code) => code .replace("x = 'X'", "x = 'Y'") .replace('reloaded >>>', 'reloaded (2) >>>') ) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ 'reloaded >>> Y', `[vite] hot updated: ${callbackUrl}` ]) } ) await untilBrowserLogAfter( () => { editFile(callbackFile, (code) => code.replace("x = 'Y'", "x = 'Z'")) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ 'reloaded (2) >>> Z', `[vite] hot updated: ${callbackUrl}` ]) } ) }) it('stops HMR bubble on dependency change', async () => { const depFileName = 'dep.ts' const depFile = `${testDir}/${depFileName}` await untilBrowserLogAfter( () => { editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ `<<<<<< A0 B0 D0 ; ${dep}`, `[vite] hot updated: ${url}` ]) } ) }) it('accepts itself and refreshes on change', async () => { await untilBrowserLogAfter( () => { editFile(file, (code) => code.replace(/(\b[A-Z])0/g, '$11')) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ `<<<<<< A1 B1 D1 ; ${dep}`, `[vite] hot updated: ${url}` ]) } ) }) it('accepts itself and refreshes on 2nd change', async () => { await untilBrowserLogAfter( () => { editFile(file, (code) => code .replace(/(\b[A-Z])1/g, '$12') .replace( "acceptExports(['a', 'default']", "acceptExports(['b', 'default']" ) ) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ `<<<<<< A2 B2 D2 ; ${dep}`, `[vite] hot updated: ${url}` ]) } ) }) it('does not accept itself anymore after acceptedExports change', async () => { await untilBrowserLogAfter( async () => { editFile(file, (code) => code.replace(/(\b[A-Z])2/g, '$13')) await page.waitForEvent('load') }, [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<<<<< A3 B3 D3 ; ${dep}`) expect(logs).toContain('>>>>>> A3 D3') } ) }) }) describe('when some used exports are not accepted', () => { const testDir = baseDir + '/main-non-accepted' const namedFileName = 'named.ts' const namedFile = `${testDir}/${namedFileName}` const defaultFileName = 'default.ts' const defaultFile = `${testDir}/${defaultFileName}` const depFileName = 'dep.ts' const depFile = `${testDir}/${depFileName}` const a = 'A0' let dep = 'dep0' beforeAll(async () => { await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< named: ${a} ; ${dep}`) expect(logs).toContain(`<<< default: def0`) expect(logs).toContain(`>>>>>> ${a} def0`) } ) }) it('does not stop the HMR bubble on change to dep', async () => { await untilBrowserLogAfter( async () => { editFile(depFile, (code) => code.replace('dep0', (dep = 'dep1'))) await page.waitForEvent('load') }, [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< named: ${a} ; ${dep}`) } ) }) describe('does not stop the HMR bubble on change to self', () => { it('with named exports', async () => { await untilBrowserLogAfter( async () => { editFile(namedFile, (code) => code.replace(a, 'A1')) await page.waitForEvent('load') }, [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< named: A1 ; ${dep}`) } ) }) it('with default export', async () => { await untilBrowserLogAfter( async () => { editFile(defaultFile, (code) => code.replace('def0', 'def1')) await page.waitForEvent('load') }, [CONNECTED, />>>>>>/], (logs) => { expect(logs).toContain(`<<< default: def1`) } ) }) }) }) test('accepts itself when imported for side effects only (no bindings imported)', async () => { const testDir = baseDir + '/side-effects' const file = 'side-effects.ts' await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), [CONNECTED, />>>/], (logs) => { expect(logs).toContain('>>> side FX') } ) await untilBrowserLogAfter( () => { editFile(`${testDir}/${file}`, (code) => code.replace('>>> side FX', '>>> side FX !!') ) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ '>>> side FX !!', `[vite] hot updated: /${testDir}/${file}` ]) } ) }) describe('acceptExports([])', () => { const testDir = baseDir + '/unused-exports' test('accepts itself if no exports are imported', async () => { const fileName = 'unused.ts' const file = `${testDir}/${fileName}` const url = '/' + file await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), [CONNECTED, '-- unused --'], (logs) => { expect(logs).toContain('-- unused --') } ) await untilBrowserLogAfter( () => { editFile(file, (code) => code.replace('-- unused --', '-> unused <-') ) }, HOT_UPDATED, (logs) => { expect(logs).toEqual(['-> unused <-', `[vite] hot updated: ${url}`]) } ) }) test("doesn't accept itself if any of its exports is imported", async () => { const fileName = 'used.ts' const file = `${testDir}/${fileName}` await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), [CONNECTED, '-- used --'], (logs) => { expect(logs).toContain('-- used --') expect(logs).toContain('used:foo0') } ) await untilBrowserLogAfter( async () => { editFile(file, (code) => code.replace('foo0', 'foo1').replace('-- used --', '-> used <-') ) await page.waitForEvent('load') }, [CONNECTED, /used:foo/], (logs) => { expect(logs).toContain('-> used <-') expect(logs).toContain('used:foo1') } ) }) }) describe('indiscriminate imports: import *', () => { const testStarExports = (testDirName: string) => { const testDir = `${baseDir}/${testDirName}` it('accepts itself if all its exports are accepted', async () => { const fileName = 'deps-all-accepted.ts' const file = `${testDir}/${fileName}` const url = '/' + file await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), [CONNECTED, '>>> ready <<<'], (logs) => { expect(logs).toContain('loaded:all:a0b0c0default0') expect(logs).toContain('all >>>>>> a0, b0, c0') } ) await untilBrowserLogAfter( () => { editFile(file, (code) => code.replace(/([abc])0/g, '$11')) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ 'all >>>>>> a1, b1, c1', `[vite] hot updated: ${url}` ]) } ) await untilBrowserLogAfter( () => { editFile(file, (code) => code.replace(/([abc])1/g, '$12')) }, HOT_UPDATED, (logs) => { expect(logs).toEqual([ 'all >>>>>> a2, b2, c2', `[vite] hot updated: ${url}` ]) } ) }) it("doesn't accept itself if one export is not accepted", async () => { const fileName = 'deps-some-accepted.ts' const file = `${testDir}/${fileName}` await untilBrowserLogAfter( () => page.goto(`${viteTestUrl}/${testDir}/`), '>>> ready <<<', (logs) => { expect(logs).toContain('loaded:some:a0b0c0default0') expect(logs).toContain('some >>>>>> a0, b0, c0') } ) await untilBrowserLogAfter( async () => { editFile(file, (code) => code.replace(/([abc])0/g, '$11')) await page.waitForEvent('load') }, '>>> ready <<<', (logs) => { expect(logs).toContain('loaded:some:a1b1c1default0') expect(logs).toContain('some >>>>>> a1, b1, c1') } ) }) } describe('import * from ...', () => testStarExports('star-imports')) describe('dynamic import(...)', () => testStarExports('dynamic-imports')) }) }) test('css in html hmr', async () => { await page.goto(viteTestUrl) expect(await getBg('.import-image')).toMatch('icon') await page.goto(viteTestUrl + '/foo/') expect(await getBg('.import-image')).toMatch('icon') editFile('index.html', (code) => code.replace('url("./icon.png")', '')) await page.waitForNavigation() expect(await getBg('.import-image')).toMatch('') }) test('HTML', async () => { await page.goto(viteTestUrl + '/counter/index.html') let btn = await page.$('button') expect(await btn.textContent()).toBe('Counter 0') editFile('counter/index.html', (code) => code.replace('Counter', 'Compteur') ) await page.waitForNavigation() btn = await page.$('button') expect(await btn.textContent()).toBe('Compteur 0') }) }