diff --git a/packages/plugin-uploader/src/controllers/ControllerBase.ts b/packages/plugin-uploader/src/controllers/ControllerBase.ts new file mode 100644 index 0000000000..4b6b43421e --- /dev/null +++ b/packages/plugin-uploader/src/controllers/ControllerBase.ts @@ -0,0 +1,103 @@ +import type { Browser, Page } from "puppeteer"; +import puppeteer from "puppeteer"; +import chalk from "chalk"; +import type { BoundMessage } from "../messages"; + +const TIMEOUT_MS = 10000; + +export interface BasicAuth { + username: string; + password: string; +} + +export abstract class ControllerBase { + private _browser?: Browser; + private _page?: Page; + + public get browser() { + if (this._browser === undefined) { + throw new Error( + "Browser is not launched yet. Please call launchBrowser() first.", + ); + } + return this._browser; + } + + public set browser(value) { + this._browser = value; + } + + public get page() { + if (this._page === undefined) { + throw new Error( + "Page is not opened yet. Please call openNewPage() first.", + ); + } + return this._page; + } + + public set page(value) { + this._page = value; + } + + public async launchBrowser( + options: { + proxy?: string; + ignoreDefaultArgs?: string[]; + } = {}, + ) { + const args = options.proxy ? [`--proxy-server=${options.proxy}`] : []; + this._browser = await puppeteer.launch({ + args, + ignoreDefaultArgs: options.ignoreDefaultArgs, + headless: "shell", + }); + } + + public async closeBrowser() { + return this.browser.close(); + } + + public async openNewPage() { + this._page = await this.browser.newPage(); + } + + protected async login(options: { + baseUrl: string; + userName: string; + password: string; + boundMessage: BoundMessage; + basicAuth?: BasicAuth; + }): Promise { + const { baseUrl, userName, password, boundMessage, basicAuth } = options; + const loginUrl = `${baseUrl}/login?saml=off`; + + if (basicAuth) { + await this.page.authenticate(basicAuth); + } + + console.log(`Open ${loginUrl}`); + await this.page.goto(loginUrl); + try { + await this.page.waitForSelector(".form-username-slash", { + timeout: TIMEOUT_MS, + }); + } catch (e) { + throw chalk.red(boundMessage("Error_cannotOpenLogin")); + } + + console.log("Trying to log in..."); + await this.page.type(".form-username-slash > input.form-text", userName); + await this.page.type(".form-password-slash > input.form-text", password); + await this.page.click(".login-button"); + + try { + await this.page.waitForNavigation({ + timeout: TIMEOUT_MS, + waitUntil: "domcontentloaded", + }); + } catch (e) { + throw chalk.red(boundMessage("Error_failedLogin")); + } + } +} diff --git a/packages/plugin-uploader/src/controllers/PluginSystemController.ts b/packages/plugin-uploader/src/controllers/PluginSystemController.ts new file mode 100644 index 0000000000..61df774ae7 --- /dev/null +++ b/packages/plugin-uploader/src/controllers/PluginSystemController.ts @@ -0,0 +1,86 @@ +import type { Lang } from "../lang"; +import chalk from "chalk"; +import { getBoundMessage } from "../messages"; +import { ReactPluginSystemPage } from "../pages/ReactPluginSystemPage"; +import type { PluginSystemPageInterface } from "../pages/PluginSystemPageInterface"; +import { + IMPORT_BUTTON_SELECTOR, + OldPluginSystemPage, +} from "../pages/OldPluginSystemPage"; +import { ControllerBase } from "./ControllerBase"; + +const DETECT_PAGE_TIMEOUT_MS = 10000; +const NO_PRIVILEGE_STATUS_CODE = "CB_NO02"; + +export interface BasicAuth { + username: string; + password: string; +} + +export default class PluginSystemController extends ControllerBase { + private ui?: PluginSystemPageInterface; + + private async getUI() { + if (this.ui) { + return this.ui; + } + + const isReactPage = await this.isReactPage(); + this.ui = isReactPage + ? new ReactPluginSystemPage() + : new OldPluginSystemPage(); + + return this.ui; + } + + private async isReactPage(): Promise { + try { + await this.page.waitForSelector(IMPORT_BUTTON_SELECTOR, { + timeout: DETECT_PAGE_TIMEOUT_MS, + }); + return false; + } catch (e) { + return true; + } + } + + protected async goToPluginSystemPage(baseUrl: string) { + const pluginSystemPageUri = `${baseUrl}/k/admin/system/plugin/`; + console.log(`Navigate to ${pluginSystemPageUri}`); + return this.page.goto(pluginSystemPageUri); + } + + public async readyForUpload(options: { + baseUrl: string; + userName: string; + password: string; + lang: Lang; + basicAuth?: BasicAuth; + }): Promise { + const { baseUrl, userName, password, lang, basicAuth } = options; + const boundMessage = getBoundMessage(lang); + + await this.login({ + baseUrl, + userName, + password, + boundMessage, + basicAuth, + }); + + const response = await this.goToPluginSystemPage(baseUrl); + if ( + !response || + (response.headers()["x-cybozu-error"] && + response.headers()["x-cybozu-error"] === NO_PRIVILEGE_STATUS_CODE) + ) { + throw chalk.red(boundMessage("Error_adminPrivilege")); + } + + await (await this.getUI()).readyForImportButton(this.page, boundMessage); + } + + public async upload(pluginPath: string, lang: Lang): Promise { + await (await this.getUI()).upload(this.page, pluginPath, lang); + } +} diff --git a/packages/plugin-uploader/src/index.ts b/packages/plugin-uploader/src/index.ts index d7e6319619..cc0bee1089 100644 --- a/packages/plugin-uploader/src/index.ts +++ b/packages/plugin-uploader/src/index.ts @@ -1,109 +1,8 @@ -import chalk from "chalk"; import fs from "fs"; -import puppeteer from "puppeteer"; -import type { Browser, Page } from "puppeteer"; - -import type { Lang } from "./lang"; import { getBoundMessage } from "./messages"; - -const TIMEOUT_MS = 10000; -const UPLOAD_TIMEOUT_MS = 60000; - -interface BasicAuth { - username: string; - password: string; -} - -const launchBrowser = ( - proxy?: string, - ignoreDefaultArgs?: string[], -): Promise => { - const args = proxy ? [`--proxy-server=${proxy}`] : []; - return puppeteer.launch({ args, ignoreDefaultArgs, headless: "shell" }); -}; - -const readyForUpload = async ( - browser: Browser, - baseUrl: string, - userName: string, - password: string, - lang: Lang, - basicAuth?: BasicAuth, -): Promise => { - const m = getBoundMessage(lang); - - const page = await browser.newPage(); - const loginUrl = `${baseUrl}/login?saml=off`; - - if (basicAuth) { - await page.authenticate(basicAuth); - } - - console.log(`Open ${loginUrl}`); - await page.goto(loginUrl); - try { - await page.waitForSelector(".form-username-slash", { timeout: TIMEOUT_MS }); - } catch (e) { - throw chalk.red(m("Error_cannotOpenLogin")); - } - console.log("Trying to log in..."); - await page.type(".form-username-slash > input.form-text", userName); - await page.type(".form-password-slash > input.form-text", password); - await page.click(".login-button"); - try { - await page.waitForNavigation({ - timeout: TIMEOUT_MS, - waitUntil: "domcontentloaded", - }); - } catch (e) { - throw chalk.red(m("Error_failedLogin")); - } - - const pluginUrl = `${baseUrl}/k/admin/system/plugin/`; - console.log(`Navigate to ${pluginUrl}`); - await page.goto(pluginUrl); - - try { - await page.waitForSelector("#page-admin-system-plugin-index-addplugin", { - timeout: TIMEOUT_MS, - }); - } catch (e) { - throw chalk.red(m("Error_adminPrivilege")); - } - return page; -}; - -const upload = async ( - page: Page, - pluginPath: string, - lang: Lang, -): Promise => { - const m = getBoundMessage(lang); - console.log(`Trying to upload ${pluginPath}`); - await page.click("#page-admin-system-plugin-index-addplugin"); - - const file = await page.$('.plupload > input[type="file"]'); - if (file == null) { - throw new Error('input[type="file"] is not found'); - } - await file.uploadFile(pluginPath); - // HACK: `page.click` does not work as expected, so we use `page.evaluate` instead. - // ref: https://github.com/puppeteer/puppeteer/pull/7097#issuecomment-850348366 - await page.evaluate(() => { - const button = - document.querySelector('button[name="ok"]'); - if (button) { - button.click(); - } else { - throw new Error('button[name="ok"] is not found'); - } - }); - await page.waitForSelector(".ocean-ui-dialog", { - hidden: true, - timeout: UPLOAD_TIMEOUT_MS, - }); - console.log(`${pluginPath} ${m("Uploaded")}`); -}; +import type { BasicAuth } from "./controllers/ControllerBase"; +import PluginSystemController from "./controllers/PluginSystemController"; +import type { Lang } from "./lang"; interface Option { proxyServer?: string; @@ -120,23 +19,26 @@ export const run = async ( pluginPath: string, options: Option, ): Promise => { - let browser = await launchBrowser( - options.proxyServer, - options.puppeteerIgnoreDefaultArgs, - ); - let page: Page; const { lang, basicAuth } = options; - const m = getBoundMessage(lang); + const boundMessage = getBoundMessage(lang); + + const browserOptions = { + proxy: options.proxyServer, + ignoreDefaultArgs: options.puppeteerIgnoreDefaultArgs, + }; + const pluginSystemController = new PluginSystemController(); + await pluginSystemController.launchBrowser(browserOptions); + try { - page = await readyForUpload( - browser, + await pluginSystemController.openNewPage(); + await pluginSystemController.readyForUpload({ baseUrl, userName, password, lang, basicAuth, - ); - await upload(page, pluginPath, lang); + }); + await pluginSystemController.upload(pluginPath, lang); if (options.watch) { let uploading = false; fs.watch(pluginPath, async () => { @@ -145,30 +47,30 @@ export const run = async ( } try { uploading = true; - await upload(page, pluginPath, lang); + await pluginSystemController.upload(pluginPath, lang); } catch (e) { console.log(e); - console.log(m("Error_retry")); - await browser.close(); - browser = await launchBrowser(options.proxyServer); - page = await readyForUpload( - browser, + console.log(boundMessage("Error_retry")); + await pluginSystemController.closeBrowser(); + await pluginSystemController.launchBrowser(browserOptions); + await pluginSystemController.openNewPage(); + await pluginSystemController.readyForUpload({ baseUrl, userName, password, lang, basicAuth, - ); - await upload(page, pluginPath, lang); + }); + await pluginSystemController.upload(pluginPath, lang); } finally { uploading = false; } }); } else { - await browser.close(); + await pluginSystemController.closeBrowser(); } } catch (e) { - console.error(m("Error"), e); - await browser.close(); + console.error(boundMessage("Error"), e); + await pluginSystemController.closeBrowser(); } }; diff --git a/packages/plugin-uploader/src/messages.ts b/packages/plugin-uploader/src/messages.ts index 58b2b317e8..1b856ec59a 100644 --- a/packages/plugin-uploader/src/messages.ts +++ b/packages/plugin-uploader/src/messages.ts @@ -3,6 +3,8 @@ import type { Lang } from "./lang"; type LangMap = { [lang in Lang]: string }; type MessageMap = { [key in keyof typeof messages]: LangMap }; +export type BoundMessage = (key: keyof MessageMap) => string; + const messages = { Q_BaseUrl: { en: "Input your kintone's base URL (https://example.cybozu.com):", @@ -44,6 +46,10 @@ const messages = { en: "Cannot navigate to the plug-ins page, please retry with an account with administrator privileges", ja: "kintone管理者権限のあるユーザーで実行してください", }, + Error_notDisplayImportButton: { + en: "Import Button is not displayed.", + ja: "「読み込む」ボタンが表示されていません。", + }, Uploaded: { en: "has been uploaded!", ja: "をアップロードしました!", @@ -66,8 +72,6 @@ export const getMessage = ( * Returns a function bound lang to getMessage * @param lang */ -export const getBoundMessage = ( - lang: keyof LangMap, -): ((key: keyof MessageMap) => string) => { +export const getBoundMessage = (lang: keyof LangMap): BoundMessage => { return getMessage.bind(null, lang); }; diff --git a/packages/plugin-uploader/src/pages/OldPluginSystemPage.ts b/packages/plugin-uploader/src/pages/OldPluginSystemPage.ts new file mode 100644 index 0000000000..4af667db2d --- /dev/null +++ b/packages/plugin-uploader/src/pages/OldPluginSystemPage.ts @@ -0,0 +1,64 @@ +import type { Page } from "puppeteer"; +import type { BoundMessage } from "../messages"; +import { getBoundMessage } from "../messages"; +import chalk from "chalk"; +import type { Lang } from "../lang"; +import type { PluginSystemPageInterface } from "./PluginSystemPageInterface"; + +const TIMEOUT_MS = 10000; +const UPLOAD_TIMEOUT_MS = 60000; + +export const IMPORT_BUTTON_SELECTOR = + "#page-admin-system-plugin-index-addplugin"; +const IMPORT_PLUGIN_DIALOG_SELECTOR = ".ocean-ui-dialog"; +const FILE_SELECTOR = '.plupload > input[type="file"]'; + +export class OldPluginSystemPage implements PluginSystemPageInterface { + public async readyForImportButton( + page: Page, + boundMessage: BoundMessage, + ): Promise { + try { + await page.waitForSelector(IMPORT_BUTTON_SELECTOR, { + timeout: TIMEOUT_MS, + }); + } catch (e) { + throw chalk.red(boundMessage("Error_notDisplayImportButton")); + } + } + + public async upload( + page: Page, + pluginPath: string, + lang: Lang, + ): Promise { + const boundMessage = getBoundMessage(lang); + console.log(`Trying to upload ${pluginPath}`); + await page.click(IMPORT_BUTTON_SELECTOR); + await page.waitForSelector(IMPORT_PLUGIN_DIALOG_SELECTOR, { + timeout: TIMEOUT_MS, + }); + const file = await page.$(FILE_SELECTOR); + if (file === null) { + throw new Error('input[type="file"] is not found'); + } + await file.uploadFile(pluginPath); + // HACK: `page.click` does not work as expected, so we use `page.evaluate` instead. + // ref: https://github.com/puppeteer/puppeteer/pull/7097#issuecomment-850348366 + await page.evaluate(() => { + const button = + document.querySelector('button[name="ok"]'); + if (button) { + button.click(); + } else { + throw new Error('button[name="ok"] is not found'); + } + }); + + await page.waitForSelector(".ocean-ui-dialog", { + hidden: true, + timeout: UPLOAD_TIMEOUT_MS, + }); + console.log(`${pluginPath} ${boundMessage("Uploaded")}`); + } +} diff --git a/packages/plugin-uploader/src/pages/PluginSystemPageInterface.ts b/packages/plugin-uploader/src/pages/PluginSystemPageInterface.ts new file mode 100644 index 0000000000..b69e0738e7 --- /dev/null +++ b/packages/plugin-uploader/src/pages/PluginSystemPageInterface.ts @@ -0,0 +1,8 @@ +import type { BoundMessage } from "../messages"; +import type { Page } from "puppeteer"; +import type { Lang } from "../lang"; + +export interface PluginSystemPageInterface { + readyForImportButton(page: Page, m: BoundMessage): Promise; + upload(page: Page, pluginPath: string, lang: Lang): Promise; +} diff --git a/packages/plugin-uploader/src/pages/ReactPluginSystemPage.ts b/packages/plugin-uploader/src/pages/ReactPluginSystemPage.ts new file mode 100644 index 0000000000..89abe7d8a0 --- /dev/null +++ b/packages/plugin-uploader/src/pages/ReactPluginSystemPage.ts @@ -0,0 +1,56 @@ +import type { Page } from "puppeteer"; +import type { BoundMessage } from "../messages"; +import { getBoundMessage } from "../messages"; +import chalk from "chalk"; +import type { Lang } from "../lang"; +import type { PluginSystemPageInterface } from "./PluginSystemPageInterface"; + +const TIMEOUT_MS = 10000; +const UPLOAD_TIMEOUT_MS = 60000; + +const IMPORT_BUTTON_SELECTOR = "button[data-testid='PluginImportButton']"; +const IMPORT_PLUGIN_DIALOG_SELECTOR = + "//div[@data-testid='ImportDialog']//div[contains(@class,'_dialogContent')]"; +const FILE_SELECTOR = "label[data-testid='FileSelector'] > input[type='file']"; +const IMPORT_BUTTON_IN_DIALOG_SELECTOR = + "//div[@data-testid='ImportDialog']//div[contains(@class,'_footer')]//button[1]"; + +export class ReactPluginSystemPage implements PluginSystemPageInterface { + public async readyForImportButton( + page: Page, + boundMessage: BoundMessage, + ): Promise { + try { + await page.waitForSelector(IMPORT_BUTTON_SELECTOR, { + timeout: TIMEOUT_MS, + }); + } catch (e) { + throw chalk.blue(boundMessage("Error_notDisplayImportButton")); + } + } + + public async upload( + page: Page, + pluginPath: string, + lang: Lang, + ): Promise { + const boundMessage = getBoundMessage(lang); + console.log(`Trying to upload ${pluginPath}`); + await page.click(IMPORT_BUTTON_SELECTOR); + await page.waitForSelector(`xpath/${IMPORT_PLUGIN_DIALOG_SELECTOR}`, { + timeout: TIMEOUT_MS, + }); + const file = await page.$(FILE_SELECTOR); + if (file === null) { + throw new Error('input[type="file"] is not found'); + } + + await file.uploadFile(pluginPath); + await page.click(`xpath/${IMPORT_BUTTON_IN_DIALOG_SELECTOR}`); + await page.waitForSelector(`xpath/${IMPORT_BUTTON_IN_DIALOG_SELECTOR}`, { + hidden: true, + timeout: UPLOAD_TIMEOUT_MS, + }); + console.log(`${pluginPath} ${boundMessage("Uploaded")}`); + } +}