slidev-theme-practicum 0.3.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.
@@ -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,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()
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, resolve } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+ import { parseArgs } from 'node:util'
7
+ import { parseSync } from '@slidev/parser'
8
+ import { createStaticDistServer } from './browser-smoke-runtime.mjs'
9
+ import { inspectRenderedTypography } from './typography-browser.mjs'
10
+
11
+ function loadChromium(deckPath) {
12
+ // В установленной теме Playwright может принадлежать проекту самой колоды.
13
+ const require = createRequire(import.meta.url)
14
+ try {
15
+ return require('playwright-chromium').chromium
16
+ }
17
+ catch {
18
+ try {
19
+ return createRequire(deckPath)('playwright-chromium').chromium
20
+ }
21
+ catch {
22
+ throw new Error('Для браузерной проверки установите в проект колоды playwright-chromium: npm install -D playwright-chromium')
23
+ }
24
+ }
25
+ }
26
+
27
+ export async function checkTypography({ deckPath, distDir }) {
28
+ const absolutePath = resolve(deckPath)
29
+ const slides = parseSync(readFileSync(absolutePath, 'utf8'), absolutePath).slides
30
+ const hashRouter = slides[0]?.frontmatter.routerMode === 'hash'
31
+ if (slides.some(slide => slide.frontmatter.src))
32
+ throw new Error('Браузерная проверка пока требует колоду без включений src. Соберите слайды в один Markdown-файл и повторите проверку его сборки.')
33
+ const chromium = loadChromium(absolutePath)
34
+ const issues = []
35
+ let browser
36
+ let server
37
+ let textCount = 0
38
+ try {
39
+ server = await createStaticDistServer(resolve(distDir))
40
+ browser = await chromium.launch({ args: ['--force-color-profile=srgb'] })
41
+ const context = await browser.newContext({
42
+ colorScheme: 'light',
43
+ reducedMotion: 'reduce',
44
+ serviceWorkers: 'block',
45
+ viewport: { width: 1280, height: 720 },
46
+ })
47
+ await context.grantPermissions(['screen-wake-lock'], { origin: server.origin })
48
+ const page = await context.newPage()
49
+ page.setDefaultTimeout(15_000)
50
+ let currentSlide = 1
51
+ page.on('pageerror', error => issues.push({ page: currentSlide, code: 'browser-error', message: error.message }))
52
+ for (let index = 0; index < slides.length; index += 1) {
53
+ const slideNumber = index + 1
54
+ currentSlide = slideNumber
55
+ try {
56
+ if (!hashRouter || index === 0)
57
+ await page.goto(`${server.origin}/${hashRouter ? '#/' : ''}${slideNumber}`, { waitUntil: 'domcontentloaded' })
58
+ else
59
+ await page.evaluate(number => window.location.hash = `#/${number}`, slideNumber)
60
+ const slide = page.locator(`.slidev-page[data-slidev-no="${slideNumber}"]`)
61
+ await slide.waitFor({ state: 'visible' })
62
+ if (!await slide.locator('.Slide').count())
63
+ throw new Error('В сборке не найден слайд темы Practicum. Проверьте соответствие файла колоды и каталога сборки.')
64
+ const result = await inspectRenderedTypography(slide)
65
+ textCount += result.textCount
66
+ issues.push(...result.issues.map(issue => ({ page: slideNumber, ...issue })))
67
+ }
68
+ catch (error) {
69
+ issues.push({ page: slideNumber, code: 'render-error', message: error.message })
70
+ }
71
+ }
72
+ }
73
+ finally {
74
+ await browser?.close()
75
+ await server?.close()
76
+ }
77
+ return { issues, slideCount: slides.length, textCount }
78
+ }
79
+
80
+ async function main() {
81
+ const { values, positionals } = parseArgs({
82
+ allowPositionals: true,
83
+ options: {
84
+ dist: { type: 'string' },
85
+ help: { type: 'boolean', short: 'h' },
86
+ },
87
+ })
88
+ if (values.help) {
89
+ console.log('Использование: slidev-practicum-check-typography [slides.md] [--dist dist]\n'
90
+ + 'Проверяет типографику показанных слайдов готовой сборки. Сначала соберите эту же колоду через slidev build.')
91
+ return
92
+ }
93
+ if (positionals.length > 1)
94
+ throw new Error('Укажите один файл колоды.')
95
+ const deckPath = resolve(positionals[0] ?? 'example.md')
96
+ const result = await checkTypography({ deckPath, distDir: values.dist ?? resolve(dirname(deckPath), 'dist') })
97
+ for (const issue of result.issues)
98
+ console.error(`Слайд ${issue.page}: ${issue.message}${issue.hint ? `\n ${issue.hint}` : ''}`)
99
+ console.log(`Типографика: слайды=${result.slideCount}, текстовые фрагменты=${result.textCount}, нарушения=${result.issues.length}`)
100
+ process.exitCode = result.issues.length ? 1 : 0
101
+ }
102
+
103
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
104
+ main().catch((error) => {
105
+ console.error(error.message)
106
+ process.exitCode = 1
107
+ })
108
+ }
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'
4
+ import { join, resolve } from 'node:path'
5
+ import { chromium } from 'playwright-chromium'
6
+ import { checkTypography } from './check-typography.mjs'
7
+ import { collectRenderedTypography, inspectRenderedTypography } from './typography-browser.mjs'
8
+
9
+ const root = resolve(import.meta.dirname, '..')
10
+ const deckPath = join(root, 'tests/fixtures/typography-deck.md')
11
+ mkdirSync(join(root, '.slidev'), { recursive: true })
12
+ const workRoot = mkdtempSync(join(root, '.slidev/typography-test-'))
13
+
14
+ async function checkDom() {
15
+ const browser = await chromium.launch()
16
+ try {
17
+ const page = await browser.newPage()
18
+ await page.setContent(`
19
+ <style>
20
+ .Slot-Content { width: 600px; height: 400px; }
21
+ .Text { font-size: 24px; color: rgb(0, 0, 0); }
22
+ </style>
23
+ <div class="slidev-layout">
24
+ <div class="Header" style="font-size: 9px">Служебная шапка 2026</div>
25
+ <div class="Slot"><div class="Slot-Content">
26
+ <div class="Text">Первый <span style="color: #000">тот же цвет</span><span style="color: color(srgb 0 0 0)">цвет sRGB</span></div>
27
+ <div class="Text" style="font-size: 40px">Заголовок</div>
28
+ <data class="Text" style="font-size: 60px">42</data>
29
+ <div class="Logo" style="font-size: 1px">Логотип</div>
30
+ <svg><text style="font-size: 2px">Иллюстрация</text></svg>
31
+ <div style="opacity: 0"><span style="font-size: 3px">Скрытый текст</span></div>
32
+ </div></div>
33
+ </div>`)
34
+ const slide = page.locator('.slidev-layout')
35
+ assert.deepEqual((await inspectRenderedTypography(slide)).issues, [])
36
+ const visible = await slide.evaluate(collectRenderedTypography)
37
+ assert.deepEqual(visible.texts.map(text => text.text), ['Первый', 'тот же цвет', 'цвет sRGB', 'Заголовок', '42'])
38
+ assert.equal(new Set(visible.texts.map(text => text.color)).size, 1)
39
+
40
+ await page.locator('.Text span').last().evaluate((element) => {
41
+ element.style.color = 'color(srgb 1 0 0)'
42
+ })
43
+ assert.ok((await inspectRenderedTypography(slide)).issues.some(issue => issue.code === 'slot-colors'))
44
+
45
+ await page.locator('.Slot-Content').evaluate((content) => {
46
+ const timelineLabel = document.createElement('span')
47
+ timelineLabel.textContent = '2026'
48
+ timelineLabel.style.fontSize = '16px'
49
+ content.append(timelineLabel)
50
+ })
51
+ assert.ok((await inspectRenderedTypography(slide)).issues.some(issue => issue.code === 'text-sizes'))
52
+
53
+ await page.locator('.Text').first().evaluate((element) => {
54
+ element.setAttribute('data-text-color', 'text-muted')
55
+ element.style.transform = 'translateX(650px)'
56
+ })
57
+ const violations = (await inspectRenderedTypography(slide)).issues.map(issue => issue.code)
58
+ assert.ok(violations.includes('muted-text'))
59
+ assert.ok(violations.includes('text-overflow'))
60
+ }
61
+ finally {
62
+ await browser.close()
63
+ }
64
+ }
65
+
66
+ try {
67
+ const build = spawnSync(process.execPath, [
68
+ join(root, 'node_modules/@slidev/cli/bin/slidev.mjs'),
69
+ 'build', deckPath, '--out', workRoot,
70
+ ], { cwd: root, encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] })
71
+ assert.equal(build.status, 0, build.stderr || build.stdout)
72
+ const { issues, slideCount } = await checkTypography({ deckPath, distDir: workRoot })
73
+ assert.equal(slideCount, 9)
74
+ const codes = page => issues.filter(issue => issue.page === page).map(issue => issue.code)
75
+ for (const page of [1, 3, 8])
76
+ assert.deepEqual(codes(page), [], JSON.stringify(issues.filter(issue => issue.page === page)))
77
+ assert.ok(codes(2).includes('text-sizes'))
78
+ assert.ok(codes(4).includes('muted-text'))
79
+ assert.ok(codes(5).includes('text-overflow'))
80
+ assert.ok(codes(6).includes('number-sizes'))
81
+ assert.ok(codes(7).includes('slot-colors'))
82
+ assert.ok(codes(9).includes('text-overflow'), JSON.stringify(issues.filter(issue => issue.page === 9)))
83
+ assert.equal(issues.some(issue => ['render-error', 'browser-error'].includes(issue.code)), false, JSON.stringify(issues))
84
+ await checkDom()
85
+ console.log('Типографика: проверены 9 слайдов с явной разметкой и Markdown, цвета, исключения и геометрия DOM.')
86
+ }
87
+ finally {
88
+ rmSync(workRoot, { recursive: true, force: true })
89
+ }