slidev-theme-practicum 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +225 -22
  2. package/components/Slide.vue +12 -58
  3. package/components/Slot.vue +4 -3
  4. package/components/StepsGrid.vue +117 -0
  5. package/components/Text.vue +5 -0
  6. package/composables/deck-decors.ts +74 -0
  7. package/composables/deck-slot-markup.cjs +19 -5
  8. package/composables/decor-sources.ts +43 -0
  9. package/composables/layout-authoring.ts +34 -9
  10. package/composables/layout-recipes.ts +16 -2
  11. package/composables/layout-shorthands.ts +157 -83
  12. package/composables/local-layout-variant-files.ts +106 -0
  13. package/composables/local-layout-variants.ts +73 -0
  14. package/composables/slide-layout.ts +3 -0
  15. package/composables/text-fit-runtime.ts +7 -3
  16. package/composables/theme-foundation.ts +7 -3
  17. package/composables/typography-guard.cjs +59 -0
  18. package/composables/use-theme-config.ts +17 -10
  19. package/composables/validate-deck-layouts.cjs +95 -7
  20. package/composables/validate-deck-typography.cjs +101 -0
  21. package/env.d.ts +18 -0
  22. package/example.md +1 -1
  23. package/package.json +18 -6
  24. package/scripts/browser-smoke.mjs +85 -23
  25. package/scripts/check-accessibility.mjs +364 -0
  26. package/scripts/check-consumer.mjs +159 -0
  27. package/scripts/check-local-layout-variant-build.mjs +189 -0
  28. package/scripts/check-package.mjs +18 -2
  29. package/scripts/check-pixels.mjs +251 -0
  30. package/scripts/check-typography.mjs +108 -0
  31. package/scripts/requirements-illustrations.txt +1 -0
  32. package/scripts/test-typography.mjs +89 -0
  33. package/scripts/trace-line-art.py +422 -0
  34. package/scripts/typography-browser.mjs +134 -0
  35. package/scripts/validate-deck.cjs +23 -3
  36. package/setup/vite-plugins.ts +109 -2
  37. package/skills/slidev-practicum/SKILL.md +19 -4
  38. package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
  39. package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
  40. package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
  41. package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
  42. package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
  43. package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
  44. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
  45. package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
  46. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
  47. package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
  48. package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
  49. package/styles/index.css +20 -27
  50. package/styles/vars.css +6 -2
@@ -0,0 +1,159 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdtempSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from 'node:fs'
13
+ import { tmpdir } from 'node:os'
14
+ import { join, resolve } from 'node:path'
15
+ import process from 'node:process'
16
+ import { chromium } from 'playwright-chromium'
17
+ import { createStaticDistServer, isSameOriginUrl } from './browser-smoke-runtime.mjs'
18
+
19
+ const PROJECT_ROOT = resolve(import.meta.dirname, '..')
20
+ const CLI_PATH = resolve(PROJECT_ROOT, 'node_modules/@slidev/cli/bin/slidev.mjs')
21
+ const workRoot = mkdtempSync(join(tmpdir(), 'slidev-theme-practicum-consumer-'))
22
+ const packDir = join(workRoot, 'pack')
23
+ const consumerRoot = join(workRoot, 'consumer')
24
+ const consumerDist = join(consumerRoot, 'dist')
25
+
26
+ function run(command, args, cwd, options = {}) {
27
+ const result = spawnSync(command, args, {
28
+ cwd,
29
+ encoding: 'utf8',
30
+ env: options.env ?? process.env,
31
+ shell: process.platform === 'win32',
32
+ })
33
+
34
+ assert.equal(
35
+ result.status,
36
+ 0,
37
+ `${command} ${args.join(' ')} завершился с кодом ${result.status}\n${result.stderr || result.stdout}`,
38
+ )
39
+ return result.stdout
40
+ }
41
+
42
+ async function inspectConsumerBuild() {
43
+ const externalRequests = []
44
+ let browser
45
+ let server
46
+
47
+ try {
48
+ server = await createStaticDistServer(consumerDist)
49
+ browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
50
+ const context = await browser.newContext({
51
+ colorScheme: 'light',
52
+ reducedMotion: 'reduce',
53
+ serviceWorkers: 'block',
54
+ viewport: { width: 1280, height: 720 },
55
+ })
56
+ const page = await context.newPage()
57
+ page.on('request', (request) => {
58
+ if (!isSameOriginUrl(server.origin, request.url()))
59
+ externalRequests.push(request.url())
60
+ })
61
+ await page.goto(`${server.origin}/#/1`, { waitUntil: 'domcontentloaded' })
62
+ const slide = page.locator('.slidev-page[data-slidev-no="1"]')
63
+ await slide.waitFor({ state: 'visible' })
64
+ await page.evaluate(async () => {
65
+ await document.fonts?.ready
66
+ await new Promise(resolveFrame => requestAnimationFrame(() => requestAnimationFrame(resolveFrame)))
67
+ })
68
+
69
+ assert.equal(
70
+ (await slide.locator('h1').textContent())?.replace(/\s+/gu, ' ').trim(),
71
+ 'Тема установлена из архива',
72
+ 'внешняя презентация должна отрисовать содержимое через установленную тему',
73
+ )
74
+ assert.equal(
75
+ await slide.locator('.Slide_layout_cover.Slide_variant_grid').count(),
76
+ 1,
77
+ 'внешняя презентация должна получить layout и variant из архива темы',
78
+ )
79
+ for (const assetPath of ['/theme/favicon.svg', '/theme/photos/photo-1.webp']) {
80
+ const response = await page.request.get(`${server.origin}${assetPath}`)
81
+ assert.equal(response.status(), 200, `${assetPath} должен попасть во внешнюю сборку`)
82
+ }
83
+ assert.deepEqual([...new Set(externalRequests)], [], 'внешняя сборка не должна обращаться в сеть')
84
+ }
85
+ finally {
86
+ await browser?.close()
87
+ await server?.close()
88
+ }
89
+ }
90
+
91
+ try {
92
+ mkdirSync(packDir, { recursive: true })
93
+ mkdirSync(consumerRoot, { recursive: true })
94
+ const packOutput = run('npm', ['pack', '--json', '--pack-destination', packDir], PROJECT_ROOT)
95
+ const [pack] = JSON.parse(packOutput)
96
+ const archivePath = join(packDir, pack.filename)
97
+ assert.equal(existsSync(archivePath), true, 'npm pack должен создать архив темы')
98
+
99
+ writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({
100
+ name: 'slidev-theme-practicum-consumer-check',
101
+ private: true,
102
+ type: 'module',
103
+ }, null, 2)}\n`)
104
+ writeFileSync(join(consumerRoot, 'slides.md'), `---
105
+ theme: practicum
106
+ title: Проверка установленного архива
107
+ routerMode: hash
108
+ preload: false
109
+ layout: cover
110
+ variant: grid
111
+ mode: dark
112
+ tone: blue
113
+ themeConfig:
114
+ deckTitle: Проверка установленного архива
115
+ ---
116
+
117
+ <Slot role="primary" surface="dark" margin="4" gap="4">
118
+ <Text as="h1" size="7">Тема установлена из архива</Text>
119
+ <Text size="3" align="end" muted>Внешняя презентация собирает layout, компоненты и ресурсы только из упакованной темы.</Text>
120
+ </Slot>
121
+
122
+ <Slot role="media" surface="color" tone="green" :background="{ src: '/theme/photos/photo-1.webp' }" />
123
+
124
+ <Slot role="media" surface="dark" :decor="{ meaning: 'cover', tone: 'green' }" />
125
+
126
+ <Slot role="media" :decor="{ meaning: 'workspace', tone: 'blue' }" />
127
+ `)
128
+
129
+ run('npm', [
130
+ 'install',
131
+ '--prefer-offline',
132
+ '--ignore-scripts',
133
+ '--no-audit',
134
+ '--no-fund',
135
+ '--no-package-lock',
136
+ '--no-save',
137
+ archivePath,
138
+ ], consumerRoot)
139
+
140
+ const installedRoot = join(consumerRoot, 'node_modules/slidev-theme-practicum')
141
+ assert.equal(lstatSync(installedRoot).isSymbolicLink(), false, 'тема должна быть распакована, а не связана с рабочей копией')
142
+ assert.ok(
143
+ realpathSync(installedRoot).startsWith(realpathSync(consumerRoot)),
144
+ 'установленная тема должна находиться внутри внешнего проекта',
145
+ )
146
+ const installedManifest = JSON.parse(readFileSync(join(installedRoot, 'package.json'), 'utf8'))
147
+ const sourceManifest = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8'))
148
+ assert.equal(installedManifest.version, sourceManifest.version)
149
+
150
+ run(process.execPath, [CLI_PATH, 'build', 'slides.md', '--out', consumerDist], consumerRoot)
151
+ await inspectConsumerBuild()
152
+ console.log(
153
+ `Потребитель архива: ${pack.filename}, версия=${installedManifest.version}, `
154
+ + 'установка=локальный архив, сборка=ok, браузер=ok, внешние запросы=0, ресурсы=2',
155
+ )
156
+ }
157
+ finally {
158
+ rmSync(workRoot, { recursive: true, force: true })
159
+ }
@@ -0,0 +1,189 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import {
4
+ cpSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ } from 'node:fs'
11
+ import { join, resolve } from 'node:path'
12
+ import process from 'node:process'
13
+ import { chromium } from 'playwright-chromium'
14
+ import { createStaticDistServer } from './browser-smoke-runtime.mjs'
15
+
16
+ const PROJECT_ROOT = resolve(import.meta.dirname, '..')
17
+ const FIXTURE_ROOT = join(PROJECT_ROOT, 'tests/fixtures/local-layout-variant-deck')
18
+ const SLIDEV_CLI = join(PROJECT_ROOT, 'node_modules/@slidev/cli/bin/slidev.mjs')
19
+ const consumerParent = join(PROJECT_ROOT, '.slidev')
20
+ mkdirSync(consumerParent, { recursive: true })
21
+ const consumerRoot = mkdtempSync(join(consumerParent, 'layout-variant-consumer-'))
22
+ const deckRoot = join(consumerRoot, 'deck')
23
+ const outputRoot = join(deckRoot, 'dist')
24
+
25
+ const FRONTMATTER_SLIDES = [
26
+ {
27
+ number: 2,
28
+ selector: '.Slide-StepsGrid_linear',
29
+ values: ['Найти нужную версию', 'Восстановить контекст', 'Сгенерировать черновик', 'Проверить', 'Исправить', 'Перенести результат'],
30
+ },
31
+ {
32
+ number: 3,
33
+ selector: '.Slide-StepsGrid_staggered',
34
+ values: ['Заметить сбой', 'Повторить шаг', 'Найти правило', 'Изменить подход', 'Записать вывод'],
35
+ },
36
+ {
37
+ number: 4,
38
+ selector: '.Slide_variant_comparison-before-after',
39
+ values: ['Решение — за человеком', 'Стадия работает сама'],
40
+ },
41
+ {
42
+ number: 5,
43
+ selector: '.Slide_variant_comparison-stable-variable',
44
+ values: ['Научиться проверять гипотезу', 'Данные и уровень подсказок'],
45
+ },
46
+ ]
47
+
48
+ function normalizeVisibleText(value) {
49
+ return value.replace(/\s+/g, ' ').trim()
50
+ }
51
+
52
+ function readArtifact(directory) {
53
+ return readdirSync(directory, { withFileTypes: true })
54
+ .flatMap((entry) => {
55
+ const path = join(directory, entry.name)
56
+ return entry.isDirectory() ? readArtifact(path) : [readFileSync(path)]
57
+ })
58
+ }
59
+
60
+ async function inspectRenderedVariant() {
61
+ const diagnostics = []
62
+ const server = await createStaticDistServer(outputRoot)
63
+ const browser = await chromium.launch({ headless: true })
64
+
65
+ try {
66
+ const context = await browser.newContext({ serviceWorkers: 'block' })
67
+ await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
68
+ const page = await context.newPage()
69
+ page.on('console', (message) => {
70
+ if (message.type() === 'error')
71
+ diagnostics.push(message.text())
72
+ })
73
+ page.on('pageerror', error => diagnostics.push(error.message))
74
+
75
+ await page.goto(`${server.origin}/1`, { waitUntil: 'networkidle' })
76
+
77
+ const firstSlide = page.locator('.slidev-page[data-slidev-no="1"]')
78
+ const badge = firstSlide.locator('.LocalLayoutVariantBadge')
79
+ await firstSlide.waitFor({ state: 'visible' })
80
+ await badge.waitFor({ state: 'visible' })
81
+
82
+ assert.equal((await badge.textContent())?.trim(), 'Практика')
83
+ assert.equal((await firstSlide.locator('h1').textContent())?.trim(), 'Что запомнить')
84
+
85
+ for (const contract of FRONTMATTER_SLIDES) {
86
+ await page.goto(`${server.origin}/${contract.number}`, { waitUntil: 'networkidle' })
87
+ const slide = page.locator(`.slidev-page[data-slidev-no="${contract.number}"]`)
88
+ await slide.waitFor({ state: 'visible' })
89
+
90
+ assert.equal(
91
+ await slide.locator(contract.selector).count(),
92
+ 1,
93
+ `слайд ${contract.number}: не найден ${contract.selector}`,
94
+ )
95
+
96
+ const rendered = await slide.locator('.Text').evaluateAll(elements => elements
97
+ .filter((element) => {
98
+ const rect = element.getBoundingClientRect()
99
+ const style = getComputedStyle(element)
100
+
101
+ return rect.width > 0
102
+ && rect.height > 0
103
+ && style.display !== 'none'
104
+ && style.visibility !== 'hidden'
105
+ && style.opacity !== '0'
106
+ })
107
+ .map(element => element.textContent ?? ''))
108
+ const normalized = rendered.map(normalizeVisibleText)
109
+
110
+ for (const expected of contract.values) {
111
+ assert.ok(
112
+ normalized.includes(normalizeVisibleText(expected)),
113
+ `слайд ${contract.number}: вложенный текст front matter «${expected}» не виден; `
114
+ + `видимые Text=${JSON.stringify(normalized)}`,
115
+ )
116
+ }
117
+
118
+ const overflow = await slide.evaluate(element => ({
119
+ x: element.scrollWidth - element.clientWidth,
120
+ y: element.scrollHeight - element.clientHeight,
121
+ }))
122
+ assert.ok(overflow.x <= 1, `слайд ${contract.number}: горизонтальное переполнение ${overflow.x}px`)
123
+ assert.ok(overflow.y <= 1, `слайд ${contract.number}: вертикальное переполнение ${overflow.y}px`)
124
+ }
125
+
126
+ assert.deepEqual(diagnostics, [], diagnostics.join('\n'))
127
+ }
128
+ finally {
129
+ await browser.close()
130
+ await server.close()
131
+ }
132
+ }
133
+
134
+ try {
135
+ cpSync(FIXTURE_ROOT, deckRoot, { recursive: true })
136
+ const themeInstallRoot = join(deckRoot, 'node_modules', 'slidev-theme-practicum')
137
+ const manifest = JSON.parse(readFileSync(join(PROJECT_ROOT, 'package.json'), 'utf8'))
138
+
139
+ mkdirSync(themeInstallRoot, { recursive: true })
140
+ cpSync(join(PROJECT_ROOT, 'package.json'), join(themeInstallRoot, 'package.json'))
141
+ for (const entry of manifest.files)
142
+ cpSync(join(PROJECT_ROOT, entry), join(themeInstallRoot, entry), { recursive: true })
143
+
144
+ const deckPath = join(deckRoot, 'slides.md')
145
+ const validation = spawnSync(process.execPath, [
146
+ join(themeInstallRoot, 'scripts', 'validate-deck.cjs'),
147
+ deckPath,
148
+ ], {
149
+ cwd: deckRoot,
150
+ encoding: 'utf8',
151
+ })
152
+
153
+ assert.equal(
154
+ validation.status,
155
+ 0,
156
+ validation.stderr || validation.stdout || validation.error?.message,
157
+ )
158
+
159
+ const result = spawnSync(process.execPath, [
160
+ SLIDEV_CLI,
161
+ 'build',
162
+ deckPath,
163
+ '--out',
164
+ outputRoot,
165
+ ], {
166
+ cwd: deckRoot,
167
+ encoding: 'utf8',
168
+ })
169
+
170
+ if (result.error || result.status !== 0) {
171
+ process.stderr.write(result.stderr || result.stdout || result.error?.message || 'Сборка локального варианта завершилась без диагностики.\n')
172
+ process.exitCode = result.status || 1
173
+ }
174
+ else {
175
+ const artifact = Buffer.concat(readArtifact(outputRoot)).toString()
176
+
177
+ if (!artifact.includes('LocalLayoutVariantBadge')) {
178
+ console.error('Сборка не содержит локальный компонент, вызванный из варианта презентации.')
179
+ process.exitCode = 1
180
+ }
181
+ else {
182
+ await inspectRenderedVariant()
183
+ console.log('Локальный вариант и вложенные поля front matter отрисовались в итоговой сборке установленной темы.')
184
+ }
185
+ }
186
+ }
187
+ finally {
188
+ rmSync(consumerRoot, { recursive: true, force: true })
189
+ }
@@ -83,12 +83,28 @@ try {
83
83
  if (!files.has('LICENSE'))
84
84
  errors.push('package должен содержать LICENSE')
85
85
 
86
- if (!files.has('skills/slidev-practicum/SKILL.md'))
87
- errors.push('package должен содержать skills/slidev-practicum/SKILL.md')
86
+ for (const skillFile of [
87
+ 'skills/slidev-practicum/SKILL.md',
88
+ 'skills/slidev-practicum/references/deck-project-structure.md',
89
+ 'skills/slidev-practicum/references/contour-illustrations.md',
90
+ 'skills/slidev-practicum/references/photographic-illustrations.md',
91
+ ]) {
92
+ if (!files.has(skillFile))
93
+ errors.push(`package должен содержать ${skillFile}`)
94
+ }
88
95
 
89
96
  if (!files.has('example.md'))
90
97
  errors.push('package должен содержать example.md как каноническую колоду для авторов')
91
98
 
99
+ for (const runtimeFile of [
100
+ 'composables/local-layout-variant-files.ts',
101
+ 'composables/local-layout-variants.ts',
102
+ 'env.d.ts',
103
+ ]) {
104
+ if (!files.has(runtimeFile))
105
+ errors.push(`package должен содержать контракт локальных вариантов: ${runtimeFile}`)
106
+ }
107
+
92
108
  const defaultDeckTargets = new Set(
93
109
  Object.values(manifest.scripts ?? {})
94
110
  .flatMap(script => String(script).match(/\b[\w-]+\.md\b/g) ?? []),
@@ -0,0 +1,251 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createHash } from 'node:crypto'
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join, resolve } from 'node:path'
14
+ import { parseSync } from '@slidev/parser'
15
+ import { chromium } from 'playwright-chromium'
16
+ import { createStaticDistServer, isSameOriginUrl } from './browser-smoke-runtime.mjs'
17
+
18
+ const PROJECT_ROOT = resolve(import.meta.dirname, '..')
19
+ const DIST_DIR = resolve(PROJECT_ROOT, 'dist')
20
+ const DECK_PATH = resolve(PROJECT_ROOT, 'example.md')
21
+ const BASELINE_DIR = resolve(PROJECT_ROOT, 'tests/visual-baselines/example')
22
+ const MANIFEST_PATH = join(BASELINE_DIR, 'manifest.json')
23
+ const UPDATE_BASELINES = process.argv.includes('--update')
24
+ const VIEWPORT = { width: 960, height: 540 }
25
+ const DEVICE_SCALE_FACTOR = 1
26
+
27
+ function slideFilename(slideNumber) {
28
+ return `slide-${String(slideNumber).padStart(2, '0')}.png`
29
+ }
30
+
31
+ function sha256(buffer) {
32
+ return createHash('sha256').update(buffer).digest('hex')
33
+ }
34
+
35
+ async function settleSlide(page, slide) {
36
+ await page.evaluate(async () => {
37
+ await Promise.race([
38
+ document.fonts?.ready ?? Promise.resolve(),
39
+ new Promise(resolveTimeout => setTimeout(resolveTimeout, 3_000)),
40
+ ])
41
+ })
42
+
43
+ await slide.evaluate(async (root) => {
44
+ await Promise.all(
45
+ [...root.querySelectorAll('img')].map(image => image.decode().catch(() => undefined)),
46
+ )
47
+
48
+ await new Promise((resolveStable, rejectStable) => {
49
+ const timeoutAt = performance.now() + 3_000
50
+ let previousSignature = ''
51
+ let stableFrames = 0
52
+
53
+ const readSignature = () => [...root.querySelectorAll('.Text')]
54
+ .map(element => [
55
+ element.getAttribute('class'),
56
+ element.clientWidth,
57
+ element.clientHeight,
58
+ element.scrollWidth,
59
+ element.scrollHeight,
60
+ ].join(':'))
61
+ .join('|')
62
+
63
+ const observeFrame = () => {
64
+ const signature = readSignature()
65
+ stableFrames = signature === previousSignature ? stableFrames + 1 : 0
66
+ previousSignature = signature
67
+
68
+ if (stableFrames >= 8) {
69
+ resolveStable()
70
+ return
71
+ }
72
+
73
+ if (performance.now() >= timeoutAt) {
74
+ rejectStable(new Error('Раскладка слайда не стабилизировалась перед снимком.'))
75
+ return
76
+ }
77
+
78
+ requestAnimationFrame(observeFrame)
79
+ }
80
+
81
+ requestAnimationFrame(observeFrame)
82
+ })
83
+ })
84
+ }
85
+
86
+ async function openSlide(page, origin, slideNumber) {
87
+ const hash = `#/${slideNumber}`
88
+
89
+ if (page.url() === 'about:blank')
90
+ await page.goto(`${origin}/${hash}`, { waitUntil: 'domcontentloaded' })
91
+ else {
92
+ await page.evaluate((nextHash) => {
93
+ window.location.hash = nextHash
94
+ }, hash)
95
+ }
96
+
97
+ await page.waitForFunction(expectedHash => window.location.hash === expectedHash, hash)
98
+ const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
99
+ await slide.waitFor({ state: 'visible' })
100
+ try {
101
+ await settleSlide(page, slide)
102
+ }
103
+ catch (error) {
104
+ throw new Error(`Слайд ${slideNumber}: ${error.message}`, { cause: error })
105
+ }
106
+ return slide
107
+ }
108
+
109
+ function removeOldBaselines() {
110
+ if (!existsSync(BASELINE_DIR))
111
+ return
112
+
113
+ for (const file of readdirSync(BASELINE_DIR)) {
114
+ if (/^slide-\d+\.png$/u.test(file))
115
+ unlinkSync(join(BASELINE_DIR, file))
116
+ }
117
+ }
118
+
119
+ function baselineFiles() {
120
+ if (!existsSync(BASELINE_DIR))
121
+ return []
122
+
123
+ return readdirSync(BASELINE_DIR)
124
+ .filter(file => /^slide-\d+\.png$/u.test(file))
125
+ .sort()
126
+ }
127
+
128
+ async function run() {
129
+ const source = readFileSync(DECK_PATH, 'utf8')
130
+ const slideCount = parseSync(source, DECK_PATH).slides.length
131
+ const actualDir = join(tmpdir(), `slidev-theme-practicum-pixels-${process.pid}`)
132
+ const externalRequests = []
133
+ const screenshots = []
134
+ let browser
135
+ let server
136
+
137
+ mkdirSync(actualDir, { recursive: true })
138
+
139
+ try {
140
+ server = await createStaticDistServer(DIST_DIR)
141
+ browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
142
+ const context = await browser.newContext({
143
+ colorScheme: 'light',
144
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
145
+ locale: 'ru-RU',
146
+ reducedMotion: 'reduce',
147
+ serviceWorkers: 'block',
148
+ timezoneId: 'Europe/Moscow',
149
+ viewport: VIEWPORT,
150
+ })
151
+ const page = await context.newPage()
152
+ page.setDefaultTimeout(15_000)
153
+ page.on('request', (request) => {
154
+ if (!isSameOriginUrl(server.origin, request.url()))
155
+ externalRequests.push(request.url())
156
+ })
157
+
158
+ let fontEnvironment
159
+ for (let slideNumber = 1; slideNumber <= slideCount; slideNumber += 1) {
160
+ const slide = await openSlide(page, server.origin, slideNumber)
161
+ fontEnvironment ??= await slide.evaluate(root => ({
162
+ cssStack: getComputedStyle(root.querySelector('.slidev-layout')).fontFamily,
163
+ interAvailable: document.fonts.check('16px "Inter"'),
164
+ ysTextAvailable: document.fonts.check('16px "YS Text"'),
165
+ }))
166
+ const image = await slide.screenshot({
167
+ animations: 'disabled',
168
+ caret: 'hide',
169
+ scale: 'css',
170
+ })
171
+ screenshots.push({
172
+ file: slideFilename(slideNumber),
173
+ image,
174
+ sha256: sha256(image),
175
+ })
176
+ }
177
+
178
+ assert.deepEqual(
179
+ [...new Set(externalRequests)],
180
+ [],
181
+ `Пиксельный контур не должен обращаться во внешнюю сеть: ${externalRequests.join(', ')}`,
182
+ )
183
+
184
+ const environment = {
185
+ arch: process.arch,
186
+ chromium: browser.version(),
187
+ colorProfile: 'srgb',
188
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
189
+ fonts: fontEnvironment,
190
+ platform: process.platform,
191
+ slideCount,
192
+ viewport: VIEWPORT,
193
+ }
194
+
195
+ if (UPDATE_BASELINES) {
196
+ mkdirSync(BASELINE_DIR, { recursive: true })
197
+ removeOldBaselines()
198
+ for (const screenshot of screenshots)
199
+ writeFileSync(join(BASELINE_DIR, screenshot.file), screenshot.image)
200
+ writeFileSync(MANIFEST_PATH, `${JSON.stringify({
201
+ environment,
202
+ slides: screenshots.map(({ file, sha256: digest }) => ({ file, sha256: digest })),
203
+ }, null, 2)}\n`)
204
+ console.log(`Пиксельные эталоны обновлены: ${slideCount} слайдов, Chromium ${environment.chromium}`)
205
+ return
206
+ }
207
+
208
+ assert.equal(existsSync(MANIFEST_PATH), true, 'Отсутствует manifest пиксельных эталонов. Запустите npm run test:pixels:update.')
209
+ const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'))
210
+ assert.deepEqual(
211
+ environment,
212
+ manifest.environment,
213
+ 'Окружение пиксельной проверки отличается от принятого эталона. Обновите эталоны осознанно.',
214
+ )
215
+ assert.deepEqual(
216
+ baselineFiles(),
217
+ screenshots.map(({ file }) => file),
218
+ 'Набор PNG-эталонов не соответствует всем слайдам example.md.',
219
+ )
220
+
221
+ const failures = []
222
+ for (const screenshot of screenshots) {
223
+ const baselinePath = join(BASELINE_DIR, screenshot.file)
224
+ const baseline = readFileSync(baselinePath)
225
+ const expectedDigest = manifest.slides.find(slide => slide.file === screenshot.file)?.sha256
226
+ assert.equal(sha256(baseline), expectedDigest, `${screenshot.file}: PNG не соответствует manifest`)
227
+
228
+ if (baseline.equals(screenshot.image))
229
+ continue
230
+
231
+ const actualPath = join(actualDir, screenshot.file)
232
+ writeFileSync(actualPath, screenshot.image)
233
+ failures.push(`${screenshot.file}: эталон ${baselinePath}, результат ${actualPath}`)
234
+ }
235
+
236
+ assert.deepEqual(failures, [], `Пиксельная регрессия:\n${failures.join('\n')}`)
237
+ rmSync(actualDir, { recursive: true, force: true })
238
+ console.log(`Пиксельная регрессия: ${slideCount}/${slideCount} слайдов совпали, Chromium ${environment.chromium}`)
239
+ }
240
+ catch (error) {
241
+ if (!UPDATE_BASELINES && existsSync(actualDir) && readdirSync(actualDir).length === 0)
242
+ rmSync(actualDir, { recursive: true, force: true })
243
+ throw error
244
+ }
245
+ finally {
246
+ await browser?.close()
247
+ await server?.close()
248
+ }
249
+ }
250
+
251
+ await run()