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.
- package/README.md +225 -22
- package/components/Slide.vue +12 -58
- package/components/Slot.vue +4 -3
- package/components/StepsGrid.vue +117 -0
- package/components/Text.vue +5 -0
- package/composables/deck-decors.ts +74 -0
- package/composables/deck-slot-markup.cjs +19 -5
- package/composables/decor-sources.ts +43 -0
- package/composables/layout-authoring.ts +34 -9
- package/composables/layout-recipes.ts +16 -2
- package/composables/layout-shorthands.ts +157 -83
- package/composables/local-layout-variant-files.ts +106 -0
- package/composables/local-layout-variants.ts +73 -0
- package/composables/slide-layout.ts +3 -0
- package/composables/text-fit-runtime.ts +7 -3
- package/composables/theme-foundation.ts +7 -3
- package/composables/typography-guard.cjs +59 -0
- package/composables/use-theme-config.ts +17 -10
- package/composables/validate-deck-layouts.cjs +95 -7
- package/composables/validate-deck-typography.cjs +101 -0
- package/env.d.ts +18 -0
- package/example.md +1 -1
- package/package.json +18 -6
- package/scripts/browser-smoke.mjs +85 -23
- package/scripts/check-accessibility.mjs +364 -0
- package/scripts/check-consumer.mjs +159 -0
- package/scripts/check-local-layout-variant-build.mjs +189 -0
- package/scripts/check-package.mjs +18 -2
- package/scripts/check-pixels.mjs +251 -0
- package/scripts/check-typography.mjs +108 -0
- package/scripts/requirements-illustrations.txt +1 -0
- package/scripts/test-typography.mjs +89 -0
- package/scripts/trace-line-art.py +422 -0
- package/scripts/typography-browser.mjs +134 -0
- package/scripts/validate-deck.cjs +23 -3
- package/setup/vite-plugins.ts +109 -2
- package/skills/slidev-practicum/SKILL.md +19 -4
- package/skills/slidev-practicum/references/contour-illustrations.md +114 -0
- package/skills/slidev-practicum/references/deck-project-structure.md +136 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/balance-scales.svg +88 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/chainsaw.svg +4 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/graduation-cap.svg +23 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.png +0 -0
- package/skills/slidev-practicum/references/illustration-examples/woodcutter-axe.svg +4 -0
- package/skills/slidev-practicum/references/photographic-illustrations.md +100 -0
- package/styles/index.css +20 -27
- 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
|
-
|
|
87
|
-
|
|
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()
|