返回 oh-my-ppt
normalize-imported-html.test.ts
根目录 / tests / unit / html-editor / normalize-imported-html.test.ts
1 import os from 'os'
2 import path from 'path'
3 import { pathToFileURL } from 'node:url'
4 import * as cheerio from 'cheerio'
5 import { describe, expect, it } from 'vitest'
6 import {
7 DEFAULT_DESIGN_WIDTH,
8 hasSlideScaffold,
9 normalizeImportedHtml,
10 rewriteRelativeAssetsToSource
11 } from '../../../src/main/html-editor/html-editor-import'
12
13 const SOURCE_DIR = path.join(os.tmpdir(), 'html-editor-src-test')
14 const fileUrl = (rel: string): string => pathToFileURL(path.resolve(SOURCE_DIR, rel)).href
15
16 describe('hasSlideScaffold', () => {
17 it('detects existing ppt-page-root scaffold', () => {
18 const html = `<html><body data-page-id="p1"><main class="ppt-page-root" data-ppt-guard-root="1"></main></body></html>`
19 expect(hasSlideScaffold(html)).toBe(true)
20 })
21
22 it('returns false for plain html', () => {
23 const html = `<html><body><h1>hello</h1></body></html>`
24 expect(hasSlideScaffold(html)).toBe(false)
25 })
26 })
27
28 describe('normalizeImportedHtml', () => {
29 const docId = 'hedit-abc'
30
31 it('wraps plain html into scaffold with designWidth, keeps head, sets body[data-page-id]', () => {
32 const html = `<!DOCTYPE html><html><head><title>Hi</title><style>.a{color:red}</style></head><body><h1>Hello</h1></body></html>`
33 const {
34 html: out,
35 designWidth,
36 title
37 } = normalizeImportedHtml({
38 html,
39 sourceDir: SOURCE_DIR,
40 docId
41 })
42 expect(title).toBe('Hi')
43 expect(designWidth).toBe(DEFAULT_DESIGN_WIDTH)
44 const $ = cheerio.load(out)
45 expect($('main.ppt-page-root[data-ppt-guard-root]').length).toBe(1)
46 expect($('.ppt-page-fit-scope').length).toBe(1)
47 expect($('main.ppt-page-root').attr('data-ppt-width')).toBe(String(DEFAULT_DESIGN_WIDTH))
48 expect($('main.ppt-page-root').attr('data-ppt-height')).toBeUndefined()
49 expect($('body').attr('data-page-id')).toBe(docId)
50 expect($('head style').text()).toContain('.a{color:red}')
51 expect($('.ppt-page-fit-scope h1').text()).toBe('Hello')
52 })
53
54 it('reuses existing scaffold: drops height, keeps width as designWidth', () => {
55 const html = `<html><body><main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-width="1920" data-ppt-height="1080"><div class="ppt-page-fit-scope"><p>x</p></div></main></body></html>`
56 const { html: out, designWidth } = normalizeImportedHtml({
57 html,
58 sourceDir: SOURCE_DIR,
59 docId
60 })
61 expect(designWidth).toBe(1920)
62 const $ = cheerio.load(out)
63 expect($('main.ppt-page-root').attr('data-ppt-width')).toBe('1920')
64 expect($('main.ppt-page-root').attr('data-ppt-height')).toBeUndefined()
65 expect($('body').attr('data-page-id')).toBe(docId)
66 expect($('.ppt-page-fit-scope p').text()).toBe('x')
67 })
68
69 it('sets body[data-page-id] when missing on existing scaffold', () => {
70 const html = `<html><body><main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-width="1280"><div class="ppt-page-fit-scope"></div></main></body></html>`
71 const { html: out } = normalizeImportedHtml({ html, sourceDir: SOURCE_DIR, docId })
72 expect(cheerio.load(out)('body').attr('data-page-id')).toBe(docId)
73 })
74
75 it('replaces an existing page id when importing an existing scaffold', () => {
76 const html = `<html><body data-page-id="old-page"><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"></div></main></body></html>`
77 const { html: out } = normalizeImportedHtml({ html, sourceDir: SOURCE_DIR, docId })
78 expect(cheerio.load(out)('body').attr('data-page-id')).toBe(docId)
79 })
80
81 it('injects chart and PPT runtime scripts once', () => {
82 const html = `<html><head><script src="https://cdn.example.com/chart.v4.js"></script></head><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"></div></main></body></html>`
83 const { html: out } = normalizeImportedHtml({
84 html,
85 sourceDir: SOURCE_DIR,
86 docId,
87 runtimeScriptHrefs: ['file:///app/chart.v4.js', 'file:///app/ppt-runtime.js']
88 })
89 const $ = cheerio.load(out)
90 expect($('script[src$="chart.v4.js"]').length).toBe(2)
91 expect($('script[src$="ppt-runtime.js"]').length).toBe(1)
92 expect($('head script').eq(1).attr('src')).toBe('file:///app/chart.v4.js')
93 })
94
95 it('allows external image and video URLs in an imported document CSP without relaxing scripts', () => {
96 const html = `<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self'; media-src 'none'; script-src 'self'"></head><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"></div></main></body></html>`
97 const { html: out } = normalizeImportedHtml({ html, sourceDir: SOURCE_DIR, docId })
98 const csp = cheerio.load(out)('meta[http-equiv="Content-Security-Policy"]').attr('content')
99
100 expect(csp).toContain("img-src 'self' http: https:")
101 expect(csp).toContain('media-src http: https:')
102 expect(csp).toContain("script-src 'self'")
103 expect(csp).not.toContain('script-src *')
104 })
105
106 it('adds dedicated media directives when the imported CSP only has default-src', () => {
107 const html = `<html><head><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'"></head><body><main class="ppt-page-root" data-ppt-guard-root="1"><div class="ppt-page-fit-scope"></div></main></body></html>`
108 const { html: out } = normalizeImportedHtml({ html, sourceDir: SOURCE_DIR, docId })
109 const csp = cheerio.load(out)('meta[http-equiv="Content-Security-Policy"]').attr('content')
110
111 expect(csp).toContain("img-src 'self' data: local-asset: file: http: https:")
112 expect(csp).toContain("media-src 'self' data: local-asset: file: http: https:")
113 expect(csp).toContain("script-src 'self'")
114 })
115 })
116
117 describe('rewriteRelativeAssetsToSource', () => {
118 it('rewrites img/script/link/video src & href & poster to file://', () => {
119 const html = `<html><body>
120 <img src="./images/a.png">
121 <script src="./assets/b.js"></script>
122 <link rel="stylesheet" href="./assets/c.css">
123 <video src="./videos/v.mp4" poster="./images/p.jpg"></video>
124 </body></html>`
125 const out = rewriteRelativeAssetsToSource({ html, sourceDir: SOURCE_DIR })
126 const $ = cheerio.load(out)
127 expect($('img').attr('src')).toBe(fileUrl('images/a.png'))
128 expect($('script').attr('src')).toBe(fileUrl('assets/b.js'))
129 expect($('link').attr('href')).toBe(fileUrl('assets/c.css'))
130 expect($('video').attr('src')).toBe(fileUrl('videos/v.mp4'))
131 expect($('video').attr('poster')).toBe(fileUrl('images/p.jpg'))
132 })
133
134 it('rewrites srcset preserving descriptors', () => {
135 const html = `<img srcset="./images/a.png 1x, ./images/b.png 2x">`
136 const out = rewriteRelativeAssetsToSource({ html, sourceDir: SOURCE_DIR })
137 const $ = cheerio.load(out)
138 expect($('img').attr('srcset')).toBe(
139 `${fileUrl('images/a.png')} 1x, ${fileUrl('images/b.png')} 2x`
140 )
141 })
142
143 it('rewrites svg use href with fragment preserved', () => {
144 const html = `<svg><use href="./icons/x.svg#i"></use></svg>`
145 const out = rewriteRelativeAssetsToSource({ html, sourceDir: SOURCE_DIR })
146 const $ = cheerio.load(out)
147 expect($('use').attr('href')).toBe(`${fileUrl('icons/x.svg')}#i`)
148 })
149
150 it('rewrites inline style url() and <style> url()', () => {
151 const html = `<html><head><style>.bg{background:url("./images/bg.png")}</style></head>
152 <body><div style="background:url('./images/inline.png')"></div></body></html>`
153 const out = rewriteRelativeAssetsToSource({ html, sourceDir: SOURCE_DIR })
154 const $ = cheerio.load(out)
155 expect($('style').text()).toContain(fileUrl('images/bg.png'))
156 expect($('div').attr('style')).toContain(fileUrl('images/inline.png'))
157 })
158
159 it('keeps http(s)/data/blob/file/fragment as-is', () => {
160 const html = `<html><body>
161 <img src="https://x.com/a.png">
162 <img src="data:image/png;base64,AAAA">
163 <img src="blob:abc">
164 <a href="#section">x</a>
165 </body></html>`
166 const out = rewriteRelativeAssetsToSource({ html, sourceDir: SOURCE_DIR })
167 const $ = cheerio.load(out)
168 expect($('img').eq(0).attr('src')).toBe('https://x.com/a.png')
169 expect($('img').eq(1).attr('src')).toBe('data:image/png;base64,AAAA')
170 expect($('img').eq(2).attr('src')).toBe('blob:abc')
171 expect($('a').attr('href')).toBe('#section')
172 })
173
174 it('does not rewrite ../ escape paths', () => {
175 const html = `<img src="../secret.png">`
176 const out = rewriteRelativeAssetsToSource({ html, sourceDir: SOURCE_DIR })
177 expect(cheerio.load(out)('img').attr('src')).toBe('../secret.png')
178 })
179 })
180
180 lines TYPESCRIPT