初始化蓝鸟文件快递柜源码与部署文档
This commit is contained in:
commit
5643d22242
70 files changed
+18669
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import { test, expect, chromium } from '@playwright/test'
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
|
||||
import { createServer } from 'node:https'
|
||||
import { request } from 'node:http'
|
||||
|
||||
test('LAN works without public DNS; public HTTP and HTTPS share files through a remote proxy', async () => {
|
||||
test.setTimeout(60_000)
|
||||
const root=mkdtempSync(join(tmpdir(),'bluebird-multiple-access-'))
|
||||
const binary=resolve('../build/bluebird'), config=join(root,'config.toml')
|
||||
const lan='http://127.0.0.1:19081', http='http://files.example.test:19081', https='https://files.example.test:19082'
|
||||
writeFileSync(config,readFileSync('../deploy/config.example.toml','utf8')
|
||||
.replace('listen = "127.0.0.1:8080"','listen = "127.0.0.1:19081"')
|
||||
.replace('public_url = "http://localhost:8080"',`public_url = "${https}"`)
|
||||
.replace('access_urls = []',`access_urls = ["${http}", "${lan}"]`)
|
||||
.replace('secure_cookies = false','secure_cookies = true')
|
||||
.replace('allow_lan_http = false','allow_lan_http = true')
|
||||
.replace('trusted_proxies = []','trusted_proxies = ["127.0.0.1/32"]')
|
||||
.replace('min_free_bytes = 1073741824','min_free_bytes = 0'))
|
||||
execFileSync(binary,['init','--config',config],{env:{...process.env,BLUEBIRD_ADMIN_PASSWORD:'multiple-access-password'}})
|
||||
execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-keyout',join(root,'key.pem'),'-out',join(root,'cert.pem'),'-days','1','-subj','/CN=files.example.test'],{stdio:'ignore'})
|
||||
let worker:ChildProcess|undefined
|
||||
const proxy=createServer({key:readFileSync(join(root,'key.pem')),cert:readFileSync(join(root,'cert.pem'))},(incoming,outgoing)=>{
|
||||
const upstream=request(lan+incoming.url,{method:incoming.method,headers:{...incoming.headers,host:'127.0.0.1:19081','x-forwarded-host':incoming.headers.host,'x-forwarded-proto':'https','x-forwarded-for':'192.168.1.61'}},response=>{
|
||||
outgoing.writeHead(response.statusCode!,response.headers);response.pipe(outgoing)
|
||||
});upstream.on('error',()=>{outgoing.writeHead(502);outgoing.end()});incoming.pipe(upstream)
|
||||
})
|
||||
const browser=await chromium.launch({env:{...process.env,HTTP_PROXY:'',HTTPS_PROXY:'',ALL_PROXY:'',http_proxy:'',https_proxy:'',all_proxy:''},args:['--host-resolver-rules=MAP files.example.test 127.0.0.1','--no-proxy-server']})
|
||||
try {
|
||||
worker=spawn(binary,['serve','--config',config],{stdio:'ignore'})
|
||||
await expect.poll(async()=>{try{return(await fetch(lan+'/api/v1/health')).status}catch{return 0}}).toBe(200)
|
||||
await new Promise<void>(ready=>proxy.listen(19082,'127.0.0.1',ready))
|
||||
const context=await browser.newContext({ignoreHTTPSErrors:true}), page=await context.newPage()
|
||||
page.setDefaultTimeout(10_000)
|
||||
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
// Abort every request to the public hostname: the LAN entrance must remain usable.
|
||||
await page.route('**://files.example.test:*/**',route=>route.abort('namenotresolved'))
|
||||
await page.goto(lan);await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible()
|
||||
await page.locator('input[type=file]').setInputFiles({name:'three-entrances.txt',mimeType:'text/plain',buffer:Buffer.from('same file in LAN, HTTP and HTTPS')})
|
||||
await page.getByRole('button',{name:'上传并生成取件码'}).click()
|
||||
await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible()
|
||||
const lanLink=await page.getByRole('textbox',{name:'取件链接'}).inputValue()
|
||||
expect(lanLink.startsWith(lan+'/#/pickup/')).toBeTruthy()
|
||||
const qrBefore=await page.getByAltText('取件二维码').getAttribute('src')
|
||||
await page.locator('.share-entry .n-base-selection').click()
|
||||
await page.getByText('公网HTTPS · '+https,{exact:true}).click()
|
||||
const httpsLink=await page.getByRole('textbox',{name:'取件链接'}).inputValue()
|
||||
expect(httpsLink).toBe(lanLink.replace(lan,https))
|
||||
await expect.poll(()=>page.getByAltText('取件二维码').getAttribute('src')).not.toBe(qrBefore)
|
||||
await page.getByRole('button',{name:'完成',exact:true}).click()
|
||||
await page.unroute('**://files.example.test:*/**')
|
||||
for(const base of [lan,https,http]) {
|
||||
if(base!==lan){
|
||||
await page.goto(base);await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible()
|
||||
await page.locator('input[type=file]').setInputFiles({name:'public-upload.txt',mimeType:'text/plain',buffer:Buffer.from('public upload')})
|
||||
await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible()
|
||||
expect((await page.getByRole('textbox',{name:'取件链接'}).inputValue()).startsWith(base+'/#/pickup/')).toBeTruthy()
|
||||
await page.getByRole('button',{name:'完成',exact:true}).click()
|
||||
}
|
||||
await page.goto(lanLink.replace(lan,base))
|
||||
await expect(page.getByRole('heading',{name:'你的文件已就位'})).toBeVisible()
|
||||
const pending=page.waitForEvent('download');await page.getByRole('button',{name:'下载',exact:true}).click()
|
||||
expect(readFileSync((await (await pending).path())!,'utf8')).toBe('same file in LAN, HTTP and HTTPS')
|
||||
await page.goto(base+'/#/admin');await page.getByRole('textbox').first().fill('admin');await page.locator('input[type=password]').fill('multiple-access-password')
|
||||
await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible()
|
||||
}
|
||||
const cookies=await context.cookies(https+'/api/v1/admin/status')
|
||||
expect(cookies.some(c=>c.name==='bb_admin'&&c.secure)).toBeTruthy()
|
||||
expect(cookies.some(c=>c.name==='bb_admin_http'&&!c.secure)).toBeTruthy()
|
||||
await page.goto(http+'/#/admin');await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible()
|
||||
expect((await context.cookies(http+'/api/v1/admin/status')).some(c=>c.name==='bb_admin')).toBeFalsy()
|
||||
expect(errors).toEqual([])
|
||||
} finally {
|
||||
await browser.close();proxy.closeAllConnections();await new Promise<void>(done=>proxy.close(()=>done()))
|
||||
if(worker&&worker.exitCode===null){worker.kill('SIGTERM');await new Promise(done=>worker!.once('exit',done))}
|
||||
rmSync(root,{recursive:true,force:true})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test as base, expect, type Page, type Cookie } from '@playwright/test'
|
||||
|
||||
// Reuse one administrator login for UI fixtures, preserving the real login
|
||||
// rate limiter and leaving its remaining burst for the full transfer scenario.
|
||||
const test=base.extend<{}, {adminCookies:Cookie[]}>({
|
||||
adminCookies:[async({browser},use)=>{
|
||||
const context=await browser.newContext()
|
||||
try{
|
||||
const response=await context.request.post('http://127.0.0.1:18981/api/v1/admin/login',{headers:{Origin:'http://127.0.0.1:18981'},data:{username:'admin',password:'e2e-secret-password'}})
|
||||
expect(response.status()).toBe(200)
|
||||
await use(await context.cookies())
|
||||
}finally{await context.close()}
|
||||
},{scope:'worker'}]
|
||||
})
|
||||
|
||||
async function adminFixture(page: Page) {
|
||||
const parcelQueries: URLSearchParams[] = [], auditQueries: URLSearchParams[] = []
|
||||
await page.route('**/api/v1/admin/parcels?**', async route => {
|
||||
const q = new URL(route.request().url()).searchParams; parcelQueries.push(q)
|
||||
const size = Number(q.get('page_size') ?? 10), total = q.get('q') ? 1 : 23, pages = Math.max(1, Math.ceil(total / size)), current = Math.min(Number(q.get('page') ?? 1), pages)
|
||||
const items = Array.from({length: Math.min(size, total-(current-1)*size)}, (_,i) => {
|
||||
const n = (current-1)*size+i+1
|
||||
return { id: n.toString(16).padStart(32,'0'), state:'ready', ip:'203.0.113.1', created:1791187200, expires:1791792000, count:1, size:12, files:[{id:'file-'+n,name:'样例文件-'+n+'.txt',size:12,status:'ready',remaining:5,expires:1791792000}] }
|
||||
})
|
||||
await route.fulfill({json:{items,total,page:current,page_size:size,pages}})
|
||||
})
|
||||
await page.route('**/api/v1/admin/audit?**', async route => {
|
||||
const q = new URL(route.request().url()).searchParams; auditQueries.push(q)
|
||||
const size = Number(q.get('page_size') ?? 20), total = q.get('source') ? (q.get('source')==='no-match' ? 0 : 2) : 100000, pages = Math.max(1,Math.ceil(total/size)), current = Math.min(Number(q.get('page') ?? 1),pages)
|
||||
const items = Array.from({length:Math.min(size,Math.max(0,total-(current-1)*size))}, (_,i) => ({id:total-(current-1)*size-i,created:1791187200,action:q.get('action')||'admin.login',target:q.get('target')||'audit-'+(total-(current-1)*size-i),source:q.get('source')||'203.0.113.1'}))
|
||||
await route.fulfill({json:{items,total,page:current,page_size:size,pages}})
|
||||
})
|
||||
await page.goto('/#/admin')
|
||||
await expect(page.getByRole('tab',{name:'文件管理'})).toBeVisible()
|
||||
await expect(page.getByRole('navigation',{name:'文件管理分页'})).toContainText('共 23 个包裹')
|
||||
return {parcelQueries,auditQueries}
|
||||
}
|
||||
|
||||
for(const mobile of [false,true]) {
|
||||
test(`admin tabs expand naturally and lists paginate/filter (${mobile?'mobile':'desktop'})`,async({page,adminCookies})=>{
|
||||
await page.context().addCookies(adminCookies)
|
||||
if(mobile)await page.setViewportSize({width:390,height:844})
|
||||
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
const {parcelQueries,auditQueries}=await adminFixture(page)
|
||||
const pager=page.getByRole('navigation',{name:'文件管理分页'})
|
||||
await expect(page.locator('.admin-parcel:visible')).toHaveCount(10)
|
||||
await pager.getByRole('button',{name:'上一页',exact:true}).isDisabled().then(v=>expect(v).toBeTruthy())
|
||||
await pager.getByRole('button',{name:'下一页',exact:true}).click()
|
||||
await expect(pager).toContainText('第 2 / 3 页');await expect(page.getByText('样例文件-11.txt',{exact:true})).toBeVisible()
|
||||
expect(parcelQueries.at(-1)?.get('page')).toBe('2')
|
||||
await pager.getByRole('button',{name:'下一页',exact:true}).click()
|
||||
await expect(page.locator('.admin-parcel:visible')).toHaveCount(3)
|
||||
await expect(pager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.getByLabel('文件名或包裹 ID',{exact:true}).fill('样例文件-2')
|
||||
await page.locator('.parcel-filters').getByRole('button',{name:'搜索',exact:true}).click()
|
||||
await expect(pager).toContainText('共 1 个包裹');await expect(pager).toContainText('第 1 / 1 页')
|
||||
expect(parcelQueries.at(-1)?.get('q')).toBe('样例文件-2')
|
||||
await expect(pager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.locator('.parcel-filters').getByRole('button',{name:'重置',exact:true}).click()
|
||||
await expect(pager).toContainText('共 23 个包裹')
|
||||
for(const name of ['文件管理','服务策略','容量与保留','操作审计']){
|
||||
await page.getByRole('tab',{name,exact:true}).click()
|
||||
expect(await page.locator('.admin-panel:visible .admin-scroll').evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBeTruthy()
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy()
|
||||
}
|
||||
const auditPager=page.getByRole('navigation',{name:'操作审计分页'})
|
||||
await expect(page.locator('.audit-table tbody tr')).toHaveCount(20)
|
||||
await expect(auditPager).toContainText('共 100000 条')
|
||||
await expect(auditPager).toContainText('第 1 / 5000 页')
|
||||
await auditPager.getByRole('button',{name:'下一页',exact:true}).click()
|
||||
await expect(auditPager).toContainText('第 2 / 5000 页');expect(auditQueries.at(-1)?.get('page')).toBe('2')
|
||||
await page.getByLabel('操作审计分页跳转页码',{exact:true}).fill('5000')
|
||||
await auditPager.getByRole('button',{name:'跳转',exact:true}).click()
|
||||
await expect(auditPager).toContainText('第 5000 / 5000 页');await expect(auditPager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.getByLabel('开始时间',{exact:true}).fill('2026-10-05T00:00');await page.getByLabel('结束时间',{exact:true}).fill('2026-10-06T00:00')
|
||||
await page.getByLabel('来源 IP',{exact:true}).fill('203.0.113')
|
||||
await page.getByLabel('目标标识',{exact:true}).fill('target-fragment')
|
||||
await page.locator('.audit-filters .n-base-selection').click()
|
||||
await page.getByText('删除包裹 · admin.delete',{exact:true}).click()
|
||||
await page.locator('.audit-filters').getByRole('button',{name:'搜索',exact:true}).click()
|
||||
await expect(auditPager).toContainText('共 2 条');await expect(auditPager).toContainText('第 1 / 1 页')
|
||||
const query=auditQueries.at(-1)!;expect(query.get('action')).toBe('admin.delete');expect(query.get('source')).toBe('203.0.113');expect(query.get('target')).toBe('target-fragment');expect(query.get('from')).toBe(await page.evaluate(()=>String(new Date('2026-10-05T00:00').getTime()/1000)));expect(query.get('to')).toBe(await page.evaluate(()=>String(new Date('2026-10-06T00:00').getTime()/1000)))
|
||||
await expect(page.locator('.audit-table tbody tr')).toHaveCount(2)
|
||||
await page.getByLabel('来源 IP',{exact:true}).fill('no-match');await page.locator('.audit-filters').getByRole('button',{name:'搜索',exact:true}).click()
|
||||
await expect(page.getByText('没有符合条件的审计记录。',{exact:true})).toBeVisible();await expect(auditPager).toContainText('共 0 条');await expect(auditPager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.locator('.audit-filters').getByRole('button',{name:'重置',exact:true}).click()
|
||||
await expect(auditPager).toContainText('共 100000 条');expect(auditQueries.at(-1)?.has('source')).toBeFalsy()
|
||||
await page.getByLabel('操作审计分页每页条数',{exact:true}).selectOption('50');await expect(page.locator('.audit-table tbody tr')).toHaveCount(50);await expect(auditPager).toContainText('第 1 / 2000 页')
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
test('anonymous batch → share QR → pickup download → admin management',async({page})=>{
|
||||
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
await page.goto('/');await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible();await expect(page).toHaveTitle('蓝鸟 · 文件快递柜');await expect(page.getByRole('link',{name:'管理',exact:true})).toHaveCount(0)
|
||||
await page.locator('input[type=file]').setInputFiles([{name:'hello.txt',mimeType:'text/plain',buffer:Buffer.from('Hello Bluebird')},{name:'copy.txt',mimeType:'text/plain',buffer:Buffer.from('Hello Bluebird')}])
|
||||
await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();await expect(page.getByAltText('取件二维码')).toBeVisible();await expect(page.getByRole('button',{name:'复制取件码',exact:true})).toBeVisible()
|
||||
const url=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.goto(url)
|
||||
await expect(page.getByRole('heading',{name:'你的文件已就位'})).toBeVisible();const downloadPromise=page.waitForEvent('download');await page.getByRole('button',{name:'下载',exact:true}).first().click();const file=await downloadPromise;expect(file.suggestedFilename()).toBe('hello.txt');expect(readFileSync((await file.path())!,'utf8')).toBe('Hello Bluebird')
|
||||
await page.getByRole('button',{name:'刷新状态'}).click();await expect(page.getByText('14 B · 剩余 4 次')).toBeVisible()
|
||||
await page.goto('/#/admin');await page.getByRole('textbox').first().fill('admin');await page.locator('input[type=password]').fill('e2e-secret-password');await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible();await expect(page.getByRole('link',{name:'寄文件',exact:true})).toHaveCount(0);await expect(page).toHaveTitle('管理控制台 · 蓝鸟');await expect(page.getByText('hello.txt',{exact:true})).toBeVisible();await expect(page.getByText('1 个对象 · 2 份文件记录')).toBeVisible()
|
||||
await page.getByRole('tab',{name:'服务策略'}).click();await expect(page.getByText('匿名会话日寄件次数',{exact:true})).toBeVisible();await page.getByRole('button',{name:'保存策略'}).click();await expect(page.getByText('策略已保存,新上传会采用新策略')).toBeVisible()
|
||||
await page.getByRole('tab',{name:'容量与保留'}).click();await expect(page.getByText('文件存储预算(MiB)',{exact:true})).toBeVisible();await expect(page.getByText('审计保留天数',{exact:true})).toBeVisible();expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('mobile upload/pickup views remain within viewport',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await page.goto('/');await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy();await page.getByRole('link',{name:'取文件',exact:true}).click();await expect(page.getByRole('button',{name:'查找文件'})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('anonymous visitor can revoke their own parcel without administrator login',async({page})=>{
|
||||
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'revoke.txt',mimeType:'text/plain',buffer:Buffer.from('revoke me')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const url=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.getByRole('link',{name:'我的寄件',exact:true}).click();await expect(page.getByText('1 个文件 · 9 B')).toBeVisible();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).last().click();await expect(page.getByText('已撤销',{exact:true})).toBeVisible();await page.goto(url);await expect(page.getByRole('alert')).toContainText('取件信息无效或已失效')
|
||||
})
|
||||
|
||||
test('management receipt works after changing anonymous browser session',async({page,browser})=>{
|
||||
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'managed.txt',mimeType:'text/plain',buffer:Buffer.from('manage with receipt')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const shareUrl=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.getByRole('link',{name:'我的寄件',exact:true}).click();const exported=page.waitForEvent('download');await page.getByRole('button',{name:'导出管理凭据',exact:true}).click();const receipt=await exported;const contents=readFileSync((await receipt.path())!)
|
||||
const context=await browser.newContext();const recipient=await context.newPage();await recipient.goto('http://127.0.0.1:18981/#/mine');await recipient.locator('input[type=file]').setInputFiles({name:'receipt.json',mimeType:'application/json',buffer:contents});await expect(recipient.getByText('管理凭据已导入,仅保存在当前标签页')).toBeVisible();await recipient.getByRole('button',{name:'撤销',exact:true}).click();await recipient.getByRole('button',{name:'撤销',exact:true}).last().click();await expect(recipient.getByText('已撤销',{exact:true})).toBeVisible();await recipient.goto(shareUrl);await expect(recipient.getByRole('alert')).toContainText('取件信息无效或已失效');await context.close()
|
||||
})
|
||||
|
||||
|
||||
test('secure context copies the actual code with Clipboard API',async({page,context})=>{
|
||||
await context.grantPermissions(['clipboard-read','clipboard-write'])
|
||||
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'copy-code.txt',mimeType:'text/plain',buffer:Buffer.from('copy code only')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const code=(await page.locator('.pickup-code').innerText()).replaceAll('-','');await page.getByRole('button',{name:'复制取件码',exact:true}).click();expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe(code);await expect(page.getByText('取件码已复制',{exact:true})).toBeVisible();const icon=await page.request.get('/favicon.ico');expect(icon.status()).toBe(200);
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect, devices, type Page } from '@playwright/test'
|
||||
|
||||
// These tests exercise real browser clipboard operations. Upload responses are
|
||||
// fixtures; the end-to-end cabinet tests cover actual transfers independently.
|
||||
async function mockShareUpload(page: Page) {
|
||||
await page.route('**/api/v1/uploads**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path.endsWith('/publish')) await route.fulfill({ json: { code: '7K3MP9TX2R', url: 'http://127.0.0.1:18981/#/pickup/7K3MP9TX2R', expires: 2000000000 } })
|
||||
else if (path.endsWith('/uploads')) await route.fulfill({ json: { id: 'a'.repeat(32), manage_token: 'b'.repeat(43), files: [{ id: 'c'.repeat(32) }] } })
|
||||
else if (route.request().method() === 'PUT') await route.fulfill({ json: {} })
|
||||
else await route.abort()
|
||||
})
|
||||
}
|
||||
|
||||
async function openShare(page: Page) {
|
||||
await mockShareUpload(page)
|
||||
await page.goto('http://127.0.0.1:18981/')
|
||||
await page.locator('input[type=file]').setInputFiles({ name: 'clipboard.txt', mimeType: 'text/plain', buffer: Buffer.from('clipboard regression') })
|
||||
await page.getByRole('button', { name: '上传并生成取件码' }).click()
|
||||
await expect(page.getByRole('heading', { name: '文件已寄出' })).toBeVisible()
|
||||
}
|
||||
|
||||
const profiles = [
|
||||
{ name: 'desktop HTTP fallback', device: undefined, mode: 'absent' },
|
||||
{ name: 'Android touch HTTP fallback', device: devices['Pixel 7'], mode: 'absent' },
|
||||
// Chromium emulates the iPhone viewport/touch configuration, not Safari's engine.
|
||||
{ name: 'iPhone touch layout HTTP fallback', device: devices['iPhone 13'], mode: 'absent' },
|
||||
{ name: 'Android touch denied Clipboard API', device: devices['Pixel 7'], mode: 'denied' },
|
||||
{ name: 'Android touch secure Clipboard API', device: devices['Pixel 7'], mode: 'native' },
|
||||
]
|
||||
for (const profile of profiles) {
|
||||
test(`actual clipboard copy and paste (${profile.name})`, async ({ browser }) => {
|
||||
const context = await browser.newContext(profile.device ?? {})
|
||||
try {
|
||||
const page = await context.newPage()
|
||||
await mockShareUpload(page)
|
||||
// Localhost exposes Clipboard API. Reproduce its absence on LAN HTTP or its
|
||||
// rejection without replacing the browser's actual copy/paste operations.
|
||||
if (profile.mode !== 'native') await page.addInitScript(mode => {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: mode === 'absent' ? undefined : { writeText: async () => { throw new DOMException('Denied', 'NotAllowedError') } } })
|
||||
}, profile.mode)
|
||||
await page.goto('http://127.0.0.1:18981/')
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement('textarea')
|
||||
input.id = 'clipboard-seed'
|
||||
input.value = 'previous clipboard content'
|
||||
document.body.appendChild(input)
|
||||
})
|
||||
await page.locator('#clipboard-seed').selectText()
|
||||
await page.keyboard.press('Control+c')
|
||||
await page.locator('#clipboard-seed').fill('')
|
||||
await page.keyboard.press('Control+v')
|
||||
await expect(page.locator('#clipboard-seed')).toHaveValue('previous clipboard content')
|
||||
await page.locator('#clipboard-seed').evaluate(node => node.remove())
|
||||
// Preserve this document's seeded clipboard and upload through the normal UI.
|
||||
await page.locator('input[type=file]').setInputFiles({ name: 'clipboard.txt', mimeType: 'text/plain', buffer: Buffer.from('clipboard regression') })
|
||||
await page.getByRole('button', { name: '上传并生成取件码' }).click()
|
||||
await expect(page.getByRole('heading', { name: '文件已寄出' })).toBeVisible()
|
||||
const code = (await page.locator('.pickup-code').innerText()).replaceAll('-', '')
|
||||
expect(code).toMatch(/^[0-9A-Z]{10}$/)
|
||||
const url = await page.getByRole('textbox', { name: '取件链接' }).inputValue()
|
||||
await page.locator('.share-dialog').evaluate(node => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('aria-label', '粘贴检查')
|
||||
node.appendChild(input)
|
||||
})
|
||||
for (const [name, value] of [['复制取件码', code], ['复制取件链接', url]]) {
|
||||
const button = page.getByRole('button', { name, exact: true })
|
||||
if (profile.device) await button.tap(); else await button.click()
|
||||
await expect(page.getByText(name === '复制取件码' ? '取件码已复制' : '取件链接已复制', { exact: true })).toBeVisible()
|
||||
const paste = page.getByRole('textbox', { name: '粘贴检查' })
|
||||
await paste.fill('')
|
||||
await paste.focus()
|
||||
await expect(paste).toBeFocused()
|
||||
// Native paste reads the actual clipboard, including in touch emulation.
|
||||
await page.keyboard.press('Control+v')
|
||||
await expect(paste).toHaveValue(value)
|
||||
await expect(page.locator('.share-dialog textarea')).toHaveCount(0)
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy()
|
||||
} finally { await context.close() }
|
||||
})
|
||||
}
|
||||
|
||||
test('blocked copy offers manual copying without a success message', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
|
||||
const native = document.execCommand.bind(document)
|
||||
document.execCommand = (command, showUI, value) => command === 'copy' ? false : native(command, showUI, value)
|
||||
})
|
||||
await openShare(page)
|
||||
await page.getByRole('button', { name: '复制取件码', exact: true }).click()
|
||||
await expect(page.getByText('复制未完成,请选择或长按取件码、链接手动复制', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('取件码已复制', { exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('.share-dialog textarea')).toHaveCount(0)
|
||||
await page.locator('.pickup-code').selectText()
|
||||
expect(await page.evaluate(() => getSelection()?.toString())).toBe(await page.locator('.pickup-code').innerText())
|
||||
})
|
||||
Reference in new issue
Block a user