diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e491db0..0cf9674 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -24,3 +24,20 @@ jobs: - name: Run Tests run: npx vitest run + + build-cloudflare: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Install Dependencies + run: npm ci + + - name: Build for Cloudflare Pages + run: npm run build + env: + NITRO_PRESET: cloudflare-pages + NODE_OPTIONS: --max-old-space-size=4096 diff --git a/app/components/ButtonTheme.vue b/app/components/ButtonTheme.vue index 0879dbf..919bd77 100644 --- a/app/components/ButtonTheme.vue +++ b/app/components/ButtonTheme.vue @@ -60,9 +60,9 @@ diff --git a/app/components/Canvas.vue b/app/components/Canvas.vue index 877ddf8..5ef6bab 100644 --- a/app/components/Canvas.vue +++ b/app/components/Canvas.vue @@ -135,7 +135,7 @@ const props = defineProps({ themeType: { type: String, default: 'dark' }, }); -const emit = defineEmits(['update:width', 'update:height']); +const emit = defineEmits(['update:width', 'update:height', 'resize-start', 'resize-end']); const x = ref(null); const y = ref(null); @@ -159,6 +159,10 @@ const { zoom, aspectRatio } = toRefs(props); let resizeObserver = null; let observedSceneWindow = null; let sceneMetricsRequest = null; +let resizeRequest = null; +let pendingResize = null; +let resizeScaleX = 1; +let resizeScaleY = 1; const ratio = computed(() => { if (aspectRatio.value) { @@ -186,22 +190,55 @@ function onResizeStart(event) { resizingWidth.value = hasWidth || !!ratio.value; resizingHeight.value = hasHeight || !!ratio.value; + + const container = event.target.parentNode; + const containerRect = container.getBoundingClientRect(); + + resizeScaleX = containerRect.width / container.offsetWidth || 1; + resizeScaleY = containerRect.height / container.offsetHeight || 1; + + emit('resize-start'); } function onResizeEnd() { + flushResize(); + resizingWidth.value = false; resizingHeight.value = false; + + emit('resize-end'); } function onResize(event) { - const container = event.target.parentNode; - if (event.rect.width) { - const scaleX = container.getBoundingClientRect().width / container.offsetWidth; - emit('update:width', event.rect.width / scaleX); + pendingResize = { + width: event.rect.width, + height: event.rect.height, + }; + + if (!resizeRequest) { + resizeRequest = requestAnimationFrame(flushResize); + } +} + +function flushResize() { + if (resizeRequest) { + cancelAnimationFrame(resizeRequest); + resizeRequest = null; + } + + if (!pendingResize) { + return; + } + + const { width, height } = pendingResize; + pendingResize = null; + + if (width) { + emit('update:width', width / resizeScaleX); } - if (event.rect.height) { - const scaleY = container.getBoundingClientRect().height / container.offsetHeight; - emit('update:height', event.rect.height / scaleY); + + if (height) { + emit('update:height', height / resizeScaleY); } } @@ -275,6 +312,10 @@ onBeforeUnmount(() => { cancelAnimationFrame(sceneMetricsRequest); } + if (resizeRequest) { + cancelAnimationFrame(resizeRequest); + } + resizeObserver?.disconnect(); }); diff --git a/app/components/Editor.vue b/app/components/Editor.vue index f4ba2b6..f18ed6a 100644 --- a/app/components/Editor.vue +++ b/app/components/Editor.vue @@ -147,8 +147,6 @@ { - updateMonacoDimensions(); - watch([sizes, orientation], updateMonacoDimensions); - $bus.$on('editors:refresh', updateMonacoDimensions); -}); - onUnmounted(() => $bus.$emit('editors:refresh')); diff --git a/app/components/Monaco.vue b/app/components/Monaco.client.vue similarity index 64% rename from app/components/Monaco.vue rename to app/components/Monaco.client.vue index aea1b90..e94be4e 100644 --- a/app/components/Monaco.vue +++ b/app/components/Monaco.client.vue @@ -1,13 +1,24 @@ diff --git a/app/components/TabBackgrounds.vue b/app/components/TabBackgrounds.vue index fe29ac1..b7edaae 100644 --- a/app/components/TabBackgrounds.vue +++ b/app/components/TabBackgrounds.vue @@ -52,7 +52,8 @@ :data-ref="`button-background-${id}`" :active="background === id && !backgroundColor" @delete="$emit('delete', id)" - @click="$emit('select', id)" + @mousedown.prevent + @click="selectBackground($event, id)" /> @@ -62,7 +63,7 @@ diff --git a/app/components/TabScenes.vue b/app/components/TabScenes.vue index e1deda8..30e0f41 100644 --- a/app/components/TabScenes.vue +++ b/app/components/TabScenes.vue @@ -4,6 +4,7 @@ ', + }, + ColorPicker: passthroughStub, + ScrollArea: passthroughStub, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + expect(scrollRefIntoView).toHaveBeenCalledWith('button-background-background-20'); + + await wrapper.setProps({ background: 'background-21' }); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/TabScenes.test.js b/tests/components/TabScenes.test.js new file mode 100644 index 0000000..1680e68 --- /dev/null +++ b/tests/components/TabScenes.test.js @@ -0,0 +1,53 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import TabScenes from '~/components/TabScenes.vue'; + +const { scrollRefIntoView } = vi.hoisted(() => ({ + scrollRefIntoView: vi.fn(), +})); + +vi.mock('~/composables/useScrollRefIntoView', () => ({ + default: () => ({ scrollRefIntoView }), +})); + +describe('TabScenes', () => { + afterEach(() => { + scrollRefIntoView.mockReset(); + }); + + it('positions the selected scene only when the tab mounts', async () => { + const wrapper = mount(TabScenes, { + props: { + activeScene: 'scene-2', + scenes: [ + { + id: 'scene-1', + title: 'Scene 1', + preview: { accent: '#fff', background: '#000', window: '#111' }, + }, + { + id: 'scene-2', + title: 'Scene 2', + preview: { accent: '#fff', background: '#000', window: '#111' }, + }, + ], + }, + global: { + stubs: { + ScrollArea: { + template: '
', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + expect(scrollRefIntoView).toHaveBeenCalledWith('button-scene-scene-2'); + + await wrapper.setProps({ activeScene: 'scene-1' }); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/TabThemes.test.js b/tests/components/TabThemes.test.js new file mode 100644 index 0000000..78b42d4 --- /dev/null +++ b/tests/components/TabThemes.test.js @@ -0,0 +1,85 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import TabThemes from '~/components/TabThemes.vue'; + +const ButtonThemeStub = { + name: 'ButtonTheme', + props: ['theme'], + template: '', +}; + +describe('TabThemes', () => { + it('only mounts theme cards near the visible viewport', async () => { + const themes = Array.from({ length: 80 }, (_, index) => `theme-${index}`); + + const wrapper = mount(TabThemes, { + props: { + code: [], + theme: 'theme-40', + themes, + settings: {}, + background: {}, + languages: [], + }, + global: { + stubs: { + ButtonTheme: ButtonThemeStub, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const renderedThemes = wrapper.findAll('.theme-card').map((card) => card.text()); + + expect(renderedThemes).toContain('theme-40'); + expect(renderedThemes.length).toBeGreaterThan(0); + expect(renderedThemes.length).toBeLessThan(12); + }); + + it('does not reposition the viewport when a theme is selected', async () => { + const themes = Array.from({ length: 80 }, (_, index) => `theme-${index}`); + const wrapper = mount(TabThemes, { + props: { + code: [], + theme: 'theme-40', + themes, + settings: {}, + background: {}, + languages: [], + }, + global: { + stubs: { + ButtonTheme: ButtonThemeStub, + }, + }, + }); + + await wrapper.vm.$nextTick(); + wrapper.element.scrollLeft = 240; + + await wrapper.setProps({ theme: 'theme-42' }); + + expect(wrapper.element.scrollLeft).toBe(240); + }); + it('reveals the selected theme when the worker loads the catalog after mounting', async () => { + const wrapper = mount(TabThemes, { + props: { + code: [], + theme: 'theme-40', + themes: [], + settings: {}, + background: {}, + languages: [], + }, + global: { stubs: { ButtonTheme: ButtonThemeStub } }, + }); + await wrapper.setProps({ + themes: Array.from({ length: 80 }, (_, index) => `theme-${index}`), + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(wrapper.findAll('.theme-card').map((card) => card.text())).toContain('theme-40'); + wrapper.unmount(); + }); +}); diff --git a/tests/composables/useHighlightedCode.test.js b/tests/composables/useHighlightedCode.test.js new file mode 100644 index 0000000..1893546 --- /dev/null +++ b/tests/composables/useHighlightedCode.test.js @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { effectScope, ref } from 'vue'; +import useHighlightedCode from '~/composables/useHighlightedCode'; + +const buildCodeBlocks = vi.hoisted(() => vi.fn()); +vi.mock('~/composables/useShiki', () => ({ default: () => ({ buildCodeBlocks }) })); + +describe('useHighlightedCode', () => { + it('ignores old results as soon as code changes, even before the debounce runs', async () => { + vi.useFakeTimers(); + let finishOld; + buildCodeBlocks.mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ); + buildCodeBlocks.mockResolvedValueOnce({ blocks: ['new'] }); + const code = ref('old'); + const scope = effectScope(); + const highlighting = scope.run(() => useHighlightedCode(() => ({ code: code.value }))); + code.value = 'new'; + finishOld({ blocks: ['old'] }); + await Promise.resolve(); + expect(highlighting.result.value).toBeNull(); + expect(highlighting.loading.value).toBe(true); + + await vi.advanceTimersByTimeAsync(150); + expect(highlighting.result.value.blocks).toEqual(['new']); + expect(highlighting.loading.value).toBe(false); + scope.stop(); + vi.useRealTimers(); + }); + + it('waits for the newest request when inputs change during a flush', async () => { + let finishOld; + buildCodeBlocks.mockImplementationOnce( + () => + new Promise((resolve) => { + finishOld = resolve; + }) + ); + buildCodeBlocks.mockResolvedValueOnce({ blocks: ['new'] }); + const code = ref('old'); + const scope = effectScope(); + const highlighting = scope.run(() => useHighlightedCode(() => ({ code: code.value }))); + const saving = highlighting.flush(); + code.value = 'new'; + finishOld({ blocks: ['old'] }); + + await expect(saving).resolves.toEqual({ blocks: ['new'] }); + expect(highlighting.result.value.blocks).toEqual(['new']); + scope.stop(); + }); + + it('keeps separate preview instances independent', async () => { + let finishFirst; + buildCodeBlocks.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve; + }) + ); + buildCodeBlocks.mockResolvedValueOnce({ blocks: ['second'] }); + const scope = effectScope(); + const first = scope.run(() => useHighlightedCode(() => ({ theme: 'first' }))); + const second = scope.run(() => useHighlightedCode(() => ({ theme: 'second' }))); + await second.flush(); + finishFirst({ blocks: ['first'] }); + await first.flush(); + + expect(first.result.value.blocks).toEqual(['first']); + expect(second.result.value.blocks).toEqual(['second']); + scope.stop(); + }); + + it('cancels delayed work and ignores pending results when disposed', async () => { + vi.useFakeTimers(); + let finish; + const calls = buildCodeBlocks.mock.calls.length; + buildCodeBlocks.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const code = ref('old'); + const scope = effectScope(); + const highlighting = scope.run(() => useHighlightedCode(() => ({ code: code.value }))); + code.value = 'new'; + scope.stop(); + finish({ blocks: ['old'] }); + await vi.advanceTimersByTimeAsync(1000); + + expect(highlighting.result.value).toBeNull(); + expect(buildCodeBlocks.mock.calls.length - calls).toBe(1); + vi.useRealTimers(); + }); + + it('only highlights visible cards and refreshes their latest language on return', async () => { + const visible = ref(false); + const language = ref('php'); + const calls = buildCodeBlocks.mock.calls.length; + buildCodeBlocks.mockResolvedValue({ blocks: [] }); + const scope = effectScope(); + const highlighting = scope.run(() => + useHighlightedCode(() => ({ language: language.value }), { enabled: visible, limit: 5 }) + ); + expect(buildCodeBlocks.mock.calls.length).toBe(calls); + visible.value = true; + await highlighting.flush(); + visible.value = false; + language.value = 'json'; + expect(buildCodeBlocks.mock.calls.length - calls).toBe(1); + visible.value = true; + await highlighting.flush(); + + expect(buildCodeBlocks).toHaveBeenLastCalledWith({ language: 'json' }, 5); + scope.stop(); + }); +}); diff --git a/tests/composables/usePreviewCapture.test.js b/tests/composables/usePreviewCapture.test.js new file mode 100644 index 0000000..427f764 --- /dev/null +++ b/tests/composables/usePreviewCapture.test.js @@ -0,0 +1,123 @@ +import { mount, flushPromises } from '@vue/test-utils'; +import { defineComponent, nextTick, ref } from 'vue'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import usePreviewCapture from '~/composables/usePreviewCapture'; + +const toPng = vi.hoisted(() => vi.fn(async () => 'png')); +const toJpeg = vi.hoisted(() => vi.fn(async () => 'jpeg')); +vi.mock('html-to-image', () => ({ toPng, toJpeg })); +afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe('usePreviewCapture', () => { + it('waits for highlighting and its DOM update before exporting', async () => { + let finish; + const text = ref('old'); + const prepare = vi.fn( + () => + new Promise((resolve) => { + finish = () => { + text.value = 'latest'; + resolve(); + }; + }) + ); + let capture; + const wrapper = mount( + defineComponent({ + setup() { + const canvas = ref(null); + capture = usePreviewCapture(() => ({ $el: canvas.value }), { + prepare, + onThumbnail: vi.fn(), + }); + return { canvas, text }; + }, + template: '
{{ text }}
', + }), + { attachTo: document.body } + ); + toPng.mockImplementationOnce(async (element) => element.textContent); + const exporting = capture.capture('toPng', 2); + await flushPromises(); + await vi.dynamicImportSettled(); + expect(toPng).not.toHaveBeenCalled(); + finish(); + + await expect(exporting).resolves.toBe('latest'); + expect(toPng).toHaveBeenCalledWith( + wrapper.element, + expect.objectContaining({ pixelRatio: 2 }) + ); + wrapper.unmount(); + }); + + it('does not publish a thumbnail after switching away from its tab', async () => { + let finish; + toJpeg.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const showing = ref(true); + const update = vi.fn(); + let capture; + const Child = defineComponent({ + setup() { + const canvas = ref(null); + capture = usePreviewCapture(() => ({ $el: canvas.value }), { + prepare: async () => {}, + onThumbnail: update, + }); + return { canvas }; + }, + template: '
', + }); + const wrapper = mount( + defineComponent({ + components: { Child }, + setup: () => ({ showing }), + template: '', + }), + { attachTo: document.body } + ); + const pending = capture.captureThumbnail(); + await flushPromises(); + showing.value = false; + await nextTick(); + finish('stale image'); + await pending; + + expect(update).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('cancels scheduled thumbnails when an explicit capture runs', async () => { + vi.useFakeTimers(); + let capture; + const wrapper = mount( + defineComponent({ + setup() { + const canvas = ref(null); + capture = usePreviewCapture(() => ({ $el: canvas.value }), { + prepare: async () => {}, + onThumbnail: vi.fn(), + }); + return { canvas }; + }, + template: '
', + }), + { attachTo: document.body } + ); + capture.scheduleThumbnail(); + await capture.capture('toPng'); + await vi.advanceTimersByTimeAsync(10000); + + expect(toPng).toHaveBeenCalledOnce(); + expect(toJpeg).not.toHaveBeenCalled(); + wrapper.unmount(); + }); +}); diff --git a/tests/composables/useShiki.test.js b/tests/composables/useShiki.test.js new file mode 100644 index 0000000..d31ff0b --- /dev/null +++ b/tests/composables/useShiki.test.js @@ -0,0 +1,38 @@ +import { useNuxtApp } from '#app'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import useShiki from '~/composables/useShiki'; + +afterEach(() => vi.restoreAllMocks()); + +describe('useShiki', () => { + it('returns a complete highlighted snapshot without mutating the input', async () => { + let finish; + const tokenize = vi.spyOn(useNuxtApp().$shiki, 'tokenizeBlocks').mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const config = { + code: [{ id: 'editor', value: 'first\nsecond', added: [1], removed: [], focused: [] }], + languages: [{ id: 'editor', name: 'bash' }], + }; + const pending = useShiki().buildCodeBlocks(config, 1); + config.code[0].added.push(2); + config.code.push({ id: 'later', value: 'unrelated' }); + const tokens = [[{ content: 'first', color: '#ffffff' }]]; + finish({ + blocks: [tokens], + theme: { name: 'github-dark', type: 'dark', fg: '#ffffff', bg: '#000000' }, + }); + const result = await pending; + + expect(tokenize).toHaveBeenCalledWith({ + blocks: [{ value: 'first', language: 'shellscript' }], + theme: 'github-dark', + }); + expect(result.blocks).toEqual([{ added: [1], removed: [], focused: [], lines: tokens }]); + expect(result.themeType).toBe('dark'); + expect(config).not.toHaveProperty('theme'); + }); +}); diff --git a/tests/plugins/shiki.test.js b/tests/plugins/shiki.test.js new file mode 100644 index 0000000..16f0f31 --- /dev/null +++ b/tests/plugins/shiki.test.js @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import shikiPlugin from '~/plugins/shiki.client'; + +const OriginalWorker = globalThis.Worker; +afterEach(() => vi.stubGlobal('Worker', OriginalWorker)); + +describe('Shiki worker client', () => { + it('matches concurrent replies to their requests', async () => { + const worker = new EventTarget(); + worker.postMessage = vi.fn(); + vi.stubGlobal('Worker', function () { + return worker; + }); + const { + provide: { shiki }, + } = shikiPlugin(); + worker.dispatchEvent( + new MessageEvent('message', { + data: { id: 1, result: { languages: ['php'], themes: ['github-dark'] } }, + }) + ); + await shiki.ready; + expect(shiki.languages()).toEqual(['php']); + const first = shiki.tokenizeBlocks({ theme: 'github-dark', blocks: [] }); + const second = shiki.tokenizeBlocks({ theme: 'github-light', blocks: [] }); + await Promise.resolve(); + worker.dispatchEvent(new MessageEvent('message', { data: { id: 3, result: 'light' } })); + worker.dispatchEvent(new MessageEvent('message', { data: { id: 2, result: 'dark' } })); + + await expect(first).resolves.toBe('dark'); + await expect(second).resolves.toBe('light'); + }); + + it('rejects initialization when the worker cannot load', async () => { + const worker = new EventTarget(); + worker.postMessage = vi.fn(); + worker.terminate = vi.fn(); + vi.stubGlobal('Worker', function () { + return worker; + }); + const { + provide: { shiki }, + } = shikiPlugin(); + const ready = expect(shiki.ready).rejects.toThrow('Worker failed to load'); + worker.dispatchEvent(new ErrorEvent('error', { message: 'Worker failed to load' })); + + await ready; + await expect(shiki.tokenizeBlocks({ blocks: [] })).rejects.toThrow('Worker failed to load'); + expect(worker.terminate).toHaveBeenCalledOnce(); + }); + + it('rejects pending and future requests when the worker stops responding', async () => { + const worker = new EventTarget(); + worker.postMessage = vi.fn(); + worker.terminate = vi.fn(); + vi.stubGlobal('Worker', function () { + return worker; + }); + const { + provide: { shiki }, + } = shikiPlugin(); + worker.dispatchEvent( + new MessageEvent('message', { + data: { id: 1, result: { languages: [], themes: [] } }, + }) + ); + await shiki.ready; + const pending = expect(shiki.tokenizeBlocks({ blocks: [] })).rejects.toThrow( + 'syntax highlighter' + ); + await Promise.resolve(); + worker.dispatchEvent(new MessageEvent('messageerror')); + + await pending; + await expect(shiki.tokenizeBlocks({ blocks: [] })).rejects.toThrow('syntax highlighter'); + expect(worker.postMessage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..cf582da --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,21 @@ +import { vi } from 'vitest'; + +// Happy DOM does not implement workers. Component tests mock tokenization separately. +vi.stubGlobal( + 'Worker', + class extends EventTarget { + postMessage({ id, method }) { + if (method === 'initialize') { + queueMicrotask(() => + this.dispatchEvent( + new MessageEvent('message', { + data: { id, result: { languages: [], themes: [] } }, + }) + ) + ); + } + } + + terminate() {} + } +); diff --git a/vitest.config.js b/vitest.config.js index c0e41bb..b5154e2 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,6 +3,7 @@ import { defineVitestConfig } from '@nuxt/test-utils/config'; export default defineVitestConfig({ test: { environment: 'nuxt', + setupFiles: ['./tests/setup.js'], environmentOptions: { nuxt: { mock: { @@ -13,4 +14,3 @@ export default defineVitestConfig({ }, }, }); -