diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 965d682a5..2798dfae1 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -13,19 +13,26 @@ jobs: run: npm install - name: Install http-server run: npm install -g http-server pm2 - - name: Build e2e outputs - run: npm run dev:e2e - name: Build production outputs run: npm run build - - name: Start up mock server run: | pm2 start 'http-server ./test-e2e/example -p 12345' pm2 start 'http-server ./test-e2e/example -p 12346' - - - name: Run tests + - name: Build e2e outputs for chrome + run: npm run dev:e2e + - name: Run tests for Chrome + env: + USE_HEADLESS_PUPPETEER: true + run: npm run test-e2e + - name: Install Firefox for Puppeteer + run: npx puppeteer browsers install firefox + - name: Build firefox e2e outputs + run: npm run dev:e2e-firefox + - name: Run tests for Firefox env: USE_HEADLESS_PUPPETEER: true + USE_FIREFOX_PUPPETEER: true run: npm run test-e2e - name: Tests ✅ if: ${{ success() }} diff --git a/package.json b/package.json index d29c8719e..86bb3bbd9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:firefox": "rspack --config=rspack/rspack.dev.firefox.ts --watch", "dev:safari": "rspack --config=rspack/rspack.dev.safari.ts --watch", "dev:e2e": "rspack --config=rspack/rspack.e2e.ts", + "dev:e2e-firefox": "rspack --config=rspack/rspack.e2e.firefox.ts", "analyze": "rspack --config=rspack/rspack.analyze.ts", "android-firefox": "web-ext run -t firefox-android --firefox-apk org.mozilla.fenix -s dist_dev_firefox --adb-device ", "build": "rspack --config=rspack/rspack.prod.ts", diff --git a/rspack/rspack.e2e.firefox.ts b/rspack/rspack.e2e.firefox.ts new file mode 100644 index 000000000..8a1861728 --- /dev/null +++ b/rspack/rspack.e2e.firefox.ts @@ -0,0 +1,19 @@ +import path from "path" +import manifest from "../src/manifest-firefox" +import { E2E_NAME } from "../src/util/constant/meta" +import generateOption from "./rspack.common" + +manifest.name = E2E_NAME +// Fix the crx id for development mode +manifest.key = "clbbddpinhgdejpoepalbfnkogbobfdb" +// The manifest.json is different from Chrome's with add-on ID +manifest.browser_specific_settings = { gecko: { id: 'timer@zhy' } } + +const options = generateOption({ + outputPath: path.join(__dirname, '..', 'dist_e2e'), + manifest, + mode: "development", +}) +options.output = { ...options.output, clean: true } + +export default options diff --git a/rspack/rspack.e2e.ts b/rspack/rspack.e2e.ts index 633fbfa1d..d56107c14 100644 --- a/rspack/rspack.e2e.ts +++ b/rspack/rspack.e2e.ts @@ -10,5 +10,6 @@ const options = generateOption({ manifest, mode: "production", }) +options.output = { ...options.output, clean: true } export default options diff --git a/src/i18n/element.ts b/src/i18n/element.ts index 71c03d822..ed0791364 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -1,21 +1,21 @@ import ElementPlus from 'element-plus' -import { type Language } from "element-plus/es/locale" +import { type Language } from "element-plus/lib/locale" import { type App } from "vue" import { locale, t } from "." import calendarMessages from "./message/common/calendar" const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> } = { - zh_CN: () => import('element-plus/es/locale/lang/zh-cn'), - zh_TW: () => import('element-plus/es/locale/lang/zh-tw'), - en: () => import('element-plus/es/locale/lang/en'), - ja: () => import('element-plus/es/locale/lang/ja'), - pt_PT: () => import('element-plus/es/locale/lang/pt'), - uk: () => import('element-plus/es/locale/lang/uk'), - es: () => import('element-plus/es/locale/lang/es'), - de: () => import('element-plus/es/locale/lang/de'), - fr: () => import('element-plus/es/locale/lang/fr'), - ru: () => import('element-plus/es/locale/lang/ru'), - ar: () => import('element-plus/es/locale/lang/ar'), + zh_CN: () => import('element-plus/lib/locale/lang/zh-cn'), + zh_TW: () => import('element-plus/lib/locale/lang/zh-tw'), + en: () => import('element-plus/lib/locale/lang/en'), + ja: () => import('element-plus/lib/locale/lang/ja'), + pt_PT: () => import('element-plus/lib/locale/lang/pt'), + uk: () => import('element-plus/lib/locale/lang/uk'), + es: () => import('element-plus/lib/locale/lang/es'), + de: () => import('element-plus/lib/locale/lang/de'), + fr: () => import('element-plus/lib/locale/lang/fr'), + ru: () => import('element-plus/lib/locale/lang/ru'), + ar: () => import('element-plus/lib/locale/lang/ar'), } export const initElementLocale = async (app: App) => { diff --git a/src/pages/app/components/SiteManage/SiteManageFilter.tsx b/src/pages/app/components/SiteManage/SiteManageFilter.tsx index 7d18a6ecf..57c0fc564 100644 --- a/src/pages/app/components/SiteManage/SiteManageFilter.tsx +++ b/src/pages/app/components/SiteManage/SiteManageFilter.tsx @@ -9,86 +9,108 @@ import InputFilterItem from "@app/components/common/filter/InputFilterItem" import { useCategories } from "@app/context" import { t } from "@app/locale" import { Connection, Delete, Grid, Plus } from "@element-plus/icons-vue" +import { useState } from "@hooks" import Flex from "@pages/components/Flex" -import { computed, defineComponent, watch } from "vue" +import { computed, defineComponent, type PropType, watch } from "vue" import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" import CategoryFilter from "../common/filter/CategoryFilter" import MultiSelectFilterItem from "../common/filter/MultiSelectFilterItem" import { ALL_TYPES } from "./common" -import { useSiteManageFilter } from './useSiteManage' + +export type FilterOption = { + query?: string + types?: timer.site.Type[] + cateIds?: number[] +} type BatchOpt = 'change' | 'disassociate' | 'delete' -const _default = defineComponent<{ - onCreate: NoArgCallback - onBatchChangeCate: NoArgCallback - onBatchDisassociate: NoArgCallback - onBatchDelete: NoArgCallback -}>(props => { - const { categories } = useCategories() - const filter = useSiteManageFilter() +const _default = defineComponent({ + props: { + defaultValue: Object as PropType, + }, + emits: { + change: (_option: FilterOption) => true, + create: () => true, + batchDelete: () => true, + batchChangeCate: () => true, + batchDisassociate: () => true, + genNames: () => true, + }, + setup(props, ctx) { + const { categories } = useCategories() - const cateDisabled = computed(() => { - const types = filter.types - return !!types?.length && !types?.includes?.('normal') - }) - watch(cateDisabled, () => cateDisabled.value && (filter.cateIds = [])) + const defaultOption = props.defaultValue + const [query, setQuery] = useState(defaultOption?.query) + const [types, setTypes] = useState(defaultOption?.types) - watch(categories, () => { - const allCateIds = categories.value?.map(c => c.id) || [] - const newVal = filter.cateIds?.filter(cid => allCateIds.includes(cid)) - // If selected category is deleted, then reset the value - newVal?.length !== filter.cateIds?.length && (filter.cateIds = newVal) - }) + const cateDisabled = computed(() => !!types.value?.length && !types.value?.includes?.('normal')) + watch([cateDisabled], () => cateDisabled.value && setCateIds([])) - const items: DropdownButtonItem[] = [{ - key: 'change', - label: t(msg => msg.siteManage.cate.batchChange), - icon: Grid, - onClick: props.onBatchChangeCate, - }, { - key: 'disassociate', - label: t(msg => msg.siteManage.cate.batchDisassociate), - icon: Connection, - onClick: props.onBatchDisassociate, - }, { - key: 'delete', - label: t(msg => msg.button.batchDelete), - icon: Delete, - onClick: props.onBatchDelete, - }] + const [cateIds, setCateIds] = useState(defaultOption?.cateIds) - return () => ( - - - msg.item.host)} / ${t(msg => msg.siteManage.column.alias)}`} - onSearch={val => filter.query = val} - width={200} - /> - msg.siteManage.column.type)} - options={ALL_TYPES.map(type => ({ value: type, label: t(msg => msg.siteManage.type[type].name) }))} - defaultValue={filter.types} - onChange={val => filter.types = val as timer.site.Type[]} - /> - filter.cateIds = v} - /> - - - - msg.button.create)} - icon={Plus} - type="success" - onClick={props.onCreate} - /> + watch(categories, () => { + const allCateIds = categories.value?.map(c => c.id) || [] + const newVal = cateIds.value?.filter(cid => allCateIds.includes(cid)) + // If selected category is deleted, then reset the value + newVal?.length !== cateIds.value?.length && setCateIds(newVal) + }) + + watch([query, types, cateIds], () => ctx.emit("change", { + query: query.value, + types: types.value, + cateIds: cateIds.value, + })) + + const items: DropdownButtonItem[] = [{ + key: 'change', + label: t(msg => msg.siteManage.cate.batchChange), + icon: Grid, + onClick: () => ctx.emit('batchChangeCate'), + }, { + key: 'disassociate', + label: t(msg => msg.siteManage.cate.batchDisassociate), + icon: Connection, + onClick: () => ctx.emit('batchDisassociate'), + }, { + key: 'delete', + label: t(msg => msg.button.batchDelete), + icon: Delete, + onClick: () => ctx.emit('batchDelete'), + }] + + return () => ( + + + msg.item.host)} / ${t(msg => msg.siteManage.column.alias)}`} + onSearch={setQuery} + width={200} + /> + msg.siteManage.column.type)} + options={ALL_TYPES.map(type => ({ value: type, label: t(msg => msg.siteManage.type[type].name) }))} + defaultValue={types.value} + onChange={val => setTypes(val as timer.site.Type[])} + /> + + + + + msg.button.create)} + icon={Plus} + type="success" + onClick={() => ctx.emit("create")} + /> + - - ) + ) + } }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx index f127eb7a9..87e319a8a 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx +++ b/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx @@ -11,11 +11,10 @@ import { MagicStick } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import siteService from "@service/site-service" import { getSuffix as getPslSuffix } from "@util/psl" -import { identifySiteKey, SiteMap } from "@util/site" -import { ElIcon, ElMessage, ElPopconfirm, ElTableColumn, ElText } from "element-plus" +import { identifySiteKey } from "@util/site" +import { ElIcon, ElPopconfirm, ElTableColumn, ElText } from "element-plus" import { toUnicode as punyCode2Unicode } from "punycode" import { defineComponent, type StyleValue } from "vue" -import { useSiteManageTable } from '../../useSiteManage' function cvt2Alias(part: string): string { let decoded = part @@ -39,71 +38,59 @@ export function genInitialAlias(site: timer.site.SiteInfo): string | undefined { return parts.reverse().map(cvt2Alias).join(' ') } -const AliasColumn = defineComponent<{}>(() => { - const { pagination, refresh } = useSiteManageTable() - - const handleChange = async (newAlias: string | undefined, row: timer.site.SiteInfo) => { - newAlias = newAlias?.trim?.() - row.alias = newAlias - if (newAlias) { - await siteService.saveAlias(row, newAlias) - } else { - await siteService.removeAlias(row) +const _default = defineComponent({ + emits: { + rowAliasSaved: (_row: timer.site.SiteInfo) => true, + batchGenerate: () => true, + }, + setup: (_, ctx) => { + const handleChange = async (newAlias: string | undefined, row: timer.site.SiteInfo) => { + newAlias = newAlias?.trim?.() + row.alias = newAlias + if (newAlias) { + await siteService.saveAlias(row, newAlias) + } else { + await siteService.removeAlias(row) + } + ctx.emit("rowAliasSaved", row) } - refresh() - } - const handleBatchGenerate = async () => { - let data = pagination.value?.list - if (!data?.length) { - return ElMessage.info("No data") - } - const toSave = new SiteMap() - const items = await siteService.batchSelect(data) - items.filter(i => !i.alias).forEach(site => { - const newAlias = genInitialAlias(site) - newAlias && toSave.put(site, newAlias) - }) - await siteService.batchSaveAliasNoRewrite(toSave) - refresh() - ElMessage.success(t(msg => msg.operation.successMsg)) + return () => ( + ( + + {t(msg => msg.siteManage.column.alias)} + msg.siteManage.genAliasConfirmMsg)} + width={400} + onConfirm={() => ctx.emit('batchGenerate')} + v-slots={{ + reference: () => ( + + + + + + + + ) + }} + /> + + ), + default: ({ row }: { row: timer.site.SiteInfo }) => handleChange(val, row)} + /> + }} + /> + ) } - - return () => ( - ( - - {t(msg => msg.siteManage.column.alias)} - msg.siteManage.genAliasConfirmMsg)} - width={400} - onConfirm={handleBatchGenerate} - v-slots={{ - reference: () => ( - - - - - - - - ) - }} - /> - - ), - default: ({ row }: { row: timer.site.SiteInfo }) => handleChange(val, row)} - /> - }} - /> - ) }) -export default AliasColumn \ No newline at end of file +export default _default \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx b/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx index 202495fac..6e40247b0 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx +++ b/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx @@ -10,29 +10,29 @@ import { Delete } from "@element-plus/icons-vue" import { type ElTableRowScope } from "@pages/element-ui/table" import siteService from "@service/site-service" import { ElTableColumn } from "element-plus" -import { defineComponent } from "vue" -import { useSiteManageTable } from '../../useSiteManage' +import type { FunctionalComponent } from "vue" -const OperationColumn = defineComponent<{}>(() => { - const { refresh } = useSiteManageTable() - const handleConfirm = (key: timer.site.SiteKey) => siteService.remove(key).then(refresh).catch(() => { }) - return () => ( - msg.button.operation)} - align="center" - v-slots={ - ({ row }: ElTableRowScope) => ( - msg.button.delete)} - confirmText={t(msg => msg.siteManage.deleteConfirmMsg, { host: row.host })} - onConfirm={() => handleConfirm(row)} - /> - )} - /> - ) -}) +type Props = { onDelete?: ArgCallback } -export default OperationColumn \ No newline at end of file +const _default: FunctionalComponent = props => ( + msg.button.operation)} + align="center" + v-slots={ + ({ row }: ElTableRowScope) => ( + msg.button.delete)} + confirmText={t(msg => msg.siteManage.deleteConfirmMsg, { host: row.host })} + onConfirm={async () => { + await siteService.remove(row) + props.onDelete?.(row) + }} + /> + )} + /> +) + +export default _default \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx b/src/pages/app/components/SiteManage/SiteManageTable/index.tsx index 1dcebc3f0..ae4f47dde 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx +++ b/src/pages/app/components/SiteManage/SiteManageTable/index.tsx @@ -11,16 +11,21 @@ import { type ElTableRowScope } from "@pages/element-ui/table" import siteService from "@service/site-service" import { SiteMap } from "@util/site" import { ElMessage, ElSwitch, ElTable, ElTableColumn } from "element-plus" -import { defineComponent } from "vue" +import { defineComponent, toRaw } from "vue" import Category from "../../common/category/CategoryEditable" -import { useSiteManageTable } from '../useSiteManage' import AliasColumn, { genInitialAlias } from "./column/AliasColumn" import OperationColumn from "./column/OperationColumn" import TypeColumn from "./column/TypeColumn" -const _default = defineComponent<{}>(() => { - const { setSelected, refresh, pagination } = useSiteManageTable() +type Props = { + data?: timer.site.SiteInfo[] + onRowDelete?: ArgCallback + onRowModify?: ArgCallback + onAliasGenerated?: NoArgCallback + onSelectionChange?: ArgCallback +} +const _default = defineComponent(props => { const handleIconError = async (row: timer.site.SiteInfo) => { await siteService.removeIconUrl(row) row.iconUrl = undefined @@ -30,11 +35,11 @@ const _default = defineComponent<{}>(() => { // Save await siteService.saveRun(row, val) row.run = val - refresh() + props.onRowModify?.(toRaw(row)) } const handleBatchGenerate = async () => { - let data = pagination.value?.list + let data = props.data if (!data?.length) { return ElMessage.info("No data") } @@ -45,16 +50,16 @@ const _default = defineComponent<{}>(() => { newAlias && toSave.put(site, newAlias) }) await siteService.batchSaveAliasNoRewrite(toSave) - refresh() + props.onAliasGenerated?.() ElMessage.success(t(msg => msg.operation.successMsg)) } return () => ( (() => { ) }} /> - + msg.siteManage.column.cate)} minWidth={140} @@ -108,9 +113,9 @@ const _default = defineComponent<{}>(() => { /> )} - + ) -}) +}, { props: ['data', 'onRowDelete', 'onRowModify', 'onAliasGenerated', 'onSelectionChange'] }) export default _default diff --git a/src/pages/app/components/SiteManage/index.tsx b/src/pages/app/components/SiteManage/index.tsx index fb8c35805..536f4055f 100644 --- a/src/pages/app/components/SiteManage/index.tsx +++ b/src/pages/app/components/SiteManage/index.tsx @@ -7,28 +7,30 @@ import { t } from "@app/locale" import { Check, Close, WarnTriangleFilled } from "@element-plus/icons-vue" -import { useState, useSwitch } from "@hooks" +import { useRequest, useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import siteService, { type SiteQueryParam } from "@service/site-service" import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElMessage, ElMessageBox } from "element-plus" -import { computed, defineComponent, markRaw, ref, type VNode } from "vue" +import { computed, defineComponent, markRaw, ref } from "vue" import ContentContainer from "../common/ContentContainer" import Pagination from "../common/Pagination" import CategorySelect from "../common/category/CategorySelect" -import SiteManageFilter from "./SiteManageFilter" +import SiteManageFilter, { type FilterOption } from "./SiteManageFilter" import Modify, { type ModifyInstance } from './SiteManageModify' import SiteManageTable from "./SiteManageTable" -import { initSiteManage } from './useSiteManage' export default defineComponent(() => { - const loadingTarget = ref() - const { - page, pagination, refresh, loading, - selected - } = initSiteManage(() => loadingTarget.value?.el as HTMLElement | undefined) + const [filterOption, setFilterOption] = useState() const modify = ref() + const [page, setPage] = useState({ num: 1, size: 20 }) + const { data: pagination, refresh, loading } = useRequest(() => { + const { query: fuzzyQuery, cateIds, types } = filterOption.value || {} + const param: SiteQueryParam = { fuzzyQuery, cateIds, types } + return siteService.selectByPage(param, page.value) + }, { loadingTarget: '#site-manage-table-wrapper', deps: [filterOption, page] }) + const [selected, setSelected] = useState([]) const cateSupported = computed(() => selected?.value?.filter(supportCategory) || []) const [showCateChange, openCateChange, closeCateChange] = useSwitch(false) const [batchCate, setBatchCate] = useState() @@ -96,6 +98,8 @@ export default defineComponent(() => { return () => ( modify.value?.add?.()} onBatchChangeCate={handleChangeCate} onBatchDelete={handleBatchDelete} @@ -103,16 +107,22 @@ export default defineComponent(() => { /> ), content: () => <> - + - + { page.num = val.num, page.size = val.size }} + onChange={setPage} /> diff --git a/src/pages/app/components/common/category/CategorySelect/index.tsx b/src/pages/app/components/common/category/CategorySelect/index.tsx index f229a9159..d99bde0fb 100644 --- a/src/pages/app/components/common/category/CategorySelect/index.tsx +++ b/src/pages/app/components/common/category/CategorySelect/index.tsx @@ -1,7 +1,6 @@ import { useCategories } from "@app/context" -import { CATE_NOT_SET_ID } from '@util/site' import { ElOption, ElSelect, type SelectInstance } from "element-plus" -import { defineComponent, ref } from "vue" +import { defineComponent, type PropType, ref } from "vue" import OptionItem from "./OptionItem" import SelectFooter from "./SelectFooter" @@ -9,43 +8,45 @@ export type CategorySelectInstance = { openOptions: () => void } -type Props = { - modelValue: number | undefined - size?: "small" - width?: string - clearable?: boolean - onVisibleChange?: ArgCallback - onChange?: ArgCallback -} - -const CategorySelect = defineComponent((props, ctx) => { - const { categories } = useCategories() +const CategorySelect = defineComponent({ + props: { + modelValue: Number, + size: String as PropType<"small" | "">, + width: String, + clearable: Boolean, + }, + emits: { + visibleChange: (_visible: boolean) => true, + change: (_newVal: number | undefined) => true, + }, + setup(props, ctx) { + const { categories } = useCategories() - const selectRef = ref() - ctx.expose({ - openOptions: () => selectRef.value?.selectOption?.() - } satisfies CategorySelectInstance) + const selectRef = ref() + ctx.expose({ + openOptions: () => selectRef.value?.selectOption?.() + } satisfies CategorySelectInstance) - return () => ( - ctx.emit('change', val)} - onVisible-change={visible => ctx.emit('visibleChange', visible)} - style={{ width: props.width || '100%' }} - clearable={props.clearable} - onClear={() => ctx.emit('change', undefined)} - emptyValues={[CATE_NOT_SET_ID, undefined]} - v-slots={{ footer: () => }} - > - {categories.value?.map(c => ( - - - - ))} - - ) -}, { props: ['clearable', 'modelValue', 'size', 'width', 'onVisibleChange', 'onChange'] }) + return () => ( + ctx.emit('change', val)} + onVisible-change={visible => ctx.emit('visibleChange', visible)} + style={{ width: props.width || '100%' }} + clearable={props.clearable} + onClear={() => ctx.emit('change', undefined)} + v-slots={{ footer: () => }} + > + {categories.value?.map(c => ( + + + + ))} + + ) + } +}) export default CategorySelect \ No newline at end of file diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index fc0b9f429..3f6306dab 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -1,8 +1,5 @@ import { ElLoadingService } from "element-plus" -import { - onBeforeMount, onMounted, ref, shallowRef, watch, - type Ref, type ShallowRef, type WatchSource, -} from "vue" +import { onBeforeMount, onMounted, ref, watch, type Ref, type WatchSource } from "vue" export type RequestOption = { manual?: boolean @@ -16,13 +13,13 @@ export type RequestOption = { } export type RequestResult = { - data: ShallowRef - ts: ShallowRef + data: Ref + ts: Ref refresh: (...p: P) => void refreshAsync: (...p: P) => Promise refreshAgain: () => void - loading: ShallowRef - param: ShallowRef

+ loading: Ref + param: Ref

} const findLoadingEl = async (target: RequestOption['loadingTarget']): Promise => { @@ -60,7 +57,7 @@ export function useRequest

( deps, onSuccess, onError, } = option || {} - const data = shallowRef(defaultValue) as ShallowRef + const data = ref(defaultValue) as Ref const loading = ref(false) const param = ref

() const ts = ref(Date.now()) @@ -92,7 +89,7 @@ export function useRequest

( hook(() => refresh(...defaultParam)) } if (deps && (!Array.isArray(deps) || deps?.length)) { - watch(deps, () => refresh(...defaultParam), { deep: true }) + watch(deps, () => refresh(...defaultParam)) } const refreshAgain = () => param.value && refresh(...param.value) return { data, ts, refresh, refreshAsync, refreshAgain, loading, param } diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index 259d7b100..b4bea030e 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -1,7 +1,10 @@ -import { type Browser, launch, type Page } from "puppeteer" +import { readFile } from 'fs/promises' +import { join } from 'path' +import { type Browser, launch, type Page, SupportedBrowser } from "puppeteer" import { E2E_OUTPUT_PATH } from "../../rspack/constant" const USE_HEADLESS_PUPPETEER = !!process.env['USE_HEADLESS_PUPPETEER'] +const TARGET: SupportedBrowser = process.env['USE_FIREFOX_PUPPETEER'] ? 'firefox' : 'chrome' export interface LaunchContext { browser: Browser @@ -31,6 +34,11 @@ class LaunchContextWrapper implements LaunchContext { async openAppPage(route: string): Promise { const page = await this.browser.newPage() + if (TARGET === 'firefox') { + page.goto(`moz-extension://${this.extensionId}/static/app.html#${route}`) + await sleep(.1) + return page + } await page.goto(`chrome-extension://${this.extensionId}/static/app.html#${route}`) return page } @@ -54,24 +62,75 @@ class LaunchContextWrapper implements LaunchContext { export async function launchBrowser(dirPath?: string): Promise { dirPath = dirPath ?? E2E_OUTPUT_PATH - const browser = await launch({ - defaultViewport: null, - headless: USE_HEADLESS_PUPPETEER, - args: [ - `--disable-extensions-except=${dirPath}`, - `--load-extension=${dirPath}`, - '--start-maximized', - '--no-sandbox', - ], - }) - const serviceWorker = await browser.waitForTarget(target => target.type() === 'service_worker') - const url = serviceWorker.url() - let extensionId: string | undefined = url.split('/')[2] - if (!extensionId) { - throw new Error('Failed to detect extension id') + if (TARGET === 'chrome') { + const browser = await launch({ + defaultViewport: null, + headless: USE_HEADLESS_PUPPETEER, + args: [ + `--disable-extensions-except=${dirPath}`, + `--load-extension=${dirPath}`, + '--start-maximized', + '--no-sandbox', + ], + }) + const serviceWorker = await browser.waitForTarget(target => target.type() === 'service_worker') + const url = serviceWorker.url() + const extensionId: string | undefined = url.split('/')[2] + if (!extensionId) { + throw new Error('Failed to detect extension id') + } + return new LaunchContextWrapper(browser, extensionId) + } else if (TARGET === 'firefox') { + const browser = await launch({ + defaultViewport: null, + headless: USE_HEADLESS_PUPPETEER, + protocol: 'webDriverBiDi', + browser: 'firefox', + args: [ + '--disable-web-security', + '--no-sandbox', + ], + }) + const addonId = await browser.installExtension(dirPath) + const profileDir = getFirefoxProfileDir(browser) + if (!profileDir) { + throw new Error('Failed to get firefox profile dir') + } + const internalUUID = await readUuidFromPrefs(profileDir, addonId) + return new LaunchContextWrapper(browser, internalUUID) + } else { + throw new Error('Unsupported browser: ' + TARGET) } +} - return new LaunchContextWrapper(browser, extensionId) +function getFirefoxProfileDir(browser: Browser): string | undefined { + const proc = browser.process() + const args = proc?.spawnargs ?? [] + const idx = args.findIndex(a => a === '--profile') + if (idx >= 0 && args[idx + 1]) { + return args[idx + 1] + } + return undefined +} + +async function readUuidFromPrefs(profileDir: string, addonId: string): Promise { + const jsPath = join(profileDir, 'prefs.js') + while (true) { + try { + const text = await readFile(jsPath, 'utf-8') + const re = /user_pref\("extensions\.webextensions\.uuids",\s*"((?:\\.|[^"\\])*)"\)/ + const m = re.exec(text) + if (!m) continue + const escaped = m[1] + const jsonText = JSON.parse(`"${escaped}"`) + const mappings = JSON.parse(jsonText) + const uuid = mappings[addonId] + if (uuid) return uuid + } catch (e) { + console.info('Waiting for prefs.js to be ready...', e) + await sleep(0.1) + } + } } export function sleep(seconds: number): Promise { diff --git a/test-e2e/limit/daily-time.test.ts b/test-e2e/limit/daily-time.test.ts index a7d6513ff..44c373d15 100644 --- a/test-e2e/limit/daily-time.test.ts +++ b/test-e2e/limit/daily-time.test.ts @@ -6,7 +6,7 @@ let context: LaunchContext describe('Daily time limit', () => { beforeEach(async () => context = await launchBrowser()) - afterEach(() => context.close()) + // afterEach(() => context.close()) test('basic', async () => { const limitTime = 2 @@ -17,6 +17,7 @@ describe('Daily time limit', () => { time: limitTime, enabled: true, allowDelay: false, locked: false, } + console.log('Demo rule: ', demoRule) // 1. Insert limit rule await createLimitRule(demoRule, limitPage) diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index f3b22b4ba..c89417183 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -11,7 +11,7 @@ async function clickRunTimeChange(siteHost: string): Promise { await sitePage.keyboard.press('Enter') await sleep(.1) await sitePage.evaluate(() => { - const runTimeSwitch = document.querySelector('table > tbody > tr > td.el-table_1_column_7 .el-switch') + const runTimeSwitch = document.querySelector('#site-manage-table-wrapper table > tbody > tr > td.el-table_1_column_7 .el-switch') runTimeSwitch?.click() }) setTimeout(() => sitePage.close(), 200)