diff --git a/src/github-auth.cjs b/src/github-auth.cjs index b4eb5be3..29a1dee2 100644 --- a/src/github-auth.cjs +++ b/src/github-auth.cjs @@ -31,6 +31,7 @@ // scope that fails to arrive produces a token that signs in fine and cannot // push — a failure that surfaces at the last write of the whole flow. const { postForm, getJson } = require('./github-http.cjs'); +const { __, sprintf } = require('@wordpress/i18n'); // The WordPress organisation's OAuth application, "WordPress Contributor // Toolkit". An empty value here means this build has no sign-in configured, @@ -94,7 +95,7 @@ async function requestDeviceCode(deps = {}) { return { ok: false, reason: 'not-configured', - error: 'This build has no GitHub application configured, so it cannot sign you in.' + error: __('This build has no GitHub application configured, so it cannot sign you in.') }; } @@ -112,11 +113,13 @@ async function requestDeviceCode(deps = {}) { return { ok: false, reason: 'not-configured', - error: 'GitHub does not recognise this application, or device flow is not enabled on it.' + error: __('GitHub does not recognise this application, or device flow is not enabled on it.') }; } if (res.status !== 200 || !res.json || !res.json.device_code) { - const detail = res.json && res.json.error_description ? res.json.error_description : `GitHub returned ${res.status}`; + const detail = res.json && res.json.error_description ? res.json.error_description : + // translators: %s: an HTTP status code, such as 500. + sprintf(__('GitHub returned %s'), res.status); return { ok: false, reason: 'error', error: detail }; } @@ -163,13 +166,13 @@ async function pollForToken({ deviceCode, interval, expiresAt }, deps = {}) { let waitSeconds = Number(interval) > 0 ? Number(interval) : DEFAULT_INTERVAL_SECONDS; for (;;) { - if (isCanceled()) return { ok: false, reason: 'canceled', error: 'Sign-in was canceled.' }; + if (isCanceled()) return { ok: false, reason: 'canceled', error: __('Sign-in was canceled.') }; await wait(waitSeconds * 1000); - if (isCanceled()) return { ok: false, reason: 'canceled', error: 'Sign-in was canceled.' }; + if (isCanceled()) return { ok: false, reason: 'canceled', error: __('Sign-in was canceled.') }; // Checked after the wait rather than before it, so a code that expires // mid-sleep is reported as expired instead of being polled once more. if (now() >= expiresAt) { - return { ok: false, reason: 'expired', error: 'The code expired before it was entered.' }; + return { ok: false, reason: 'expired', error: __('The code expired before it was entered.') }; } let res; @@ -198,14 +201,15 @@ async function pollForToken({ deviceCode, interval, expiresAt }, deps = {}) { waitSeconds = Number(json.interval) > 0 ? Number(json.interval) : waitSeconds + SLOW_DOWN_BUMP_SECONDS; break; case 'expired_token': - return { ok: false, reason: 'expired', error: 'The code expired before it was entered.' }; + return { ok: false, reason: 'expired', error: __('The code expired before it was entered.') }; case 'access_denied': - return { ok: false, reason: 'denied', error: 'The authorization was declined on GitHub.' }; + return { ok: false, reason: 'denied', error: __('The authorization was declined on GitHub.') }; default: return { ok: false, reason: 'error', - error: json.error_description || json.error || `GitHub returned ${res.status}` + // translators: %s: an HTTP status code, such as 500. + error: json.error_description || json.error || sprintf(__('GitHub returned %s'), res.status) }; } } @@ -252,10 +256,11 @@ async function fetchViewer(token, deps = {}) { return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }; } if (res.status === 401) { - return { ok: false, reason: 'unauthorized', error: 'That sign-in is no longer valid.' }; + return { ok: false, reason: 'unauthorized', error: __('That sign-in is no longer valid.') }; } if (res.status !== 200 || !res.json || !res.json.login) { - return { ok: false, reason: 'error', error: `GitHub returned ${res.status}` }; + // translators: %s: an HTTP status code, such as 500. + return { ok: false, reason: 'error', error: sprintf(__('GitHub returned %s'), res.status) }; } // Only judged when GitHub states the scopes: the header is how OAuth // tokens carry them, and its absence (some token types omit it) is not @@ -265,7 +270,12 @@ async function fetchViewer(token, deps = {}) { return { ok: false, reason: 'insufficient-scope', - error: 'GitHub granted this sign-in less access than opening a pull request needs — an older authorization of this app was likely reused. Revoke "WordPress Contributor Toolkit" under github.com → Settings → Applications, then sign in here again.' + error: sprintf( + // translators: 1: the name of this app's GitHub application, WordPress Contributor Toolkit. 2: where to revoke it, github.com → Settings → Applications. + __('GitHub granted this sign-in less access than opening a pull request needs — an older authorization of this app was likely reused. Revoke "%1$s" under %2$s, then sign in here again.'), + 'WordPress Contributor Toolkit', + 'github.com → Settings → Applications' + ) }; } return { ok: true, login: String(res.json.login) }; diff --git a/src/github-pr.cjs b/src/github-pr.cjs index 0f17a3bf..8f42409b 100644 --- a/src/github-pr.cjs +++ b/src/github-pr.cjs @@ -32,6 +32,7 @@ * patch file, which is always still there — but not the same explanation. */ +const { __, _n, sprintf } = require('@wordpress/i18n'); const { getJson, postJson } = require('./github-http.cjs'); const { classifyHttpFailure } = require('./patch-sources.cjs'); const { ticketUrl } = require('./renderer/trac-ticket.cjs'); @@ -159,8 +160,22 @@ function classifyFailure(res) { * @return {{ok: false, reason: string, error: string}} */ function failure(res, what) { - const detail = res.json && res.json.message ? res.json.message : `GitHub returned ${res.status}`; - return { ok: false, reason: classifyFailure(res), error: `${what}: ${detail}${describeResponse(res)}` }; + let detail = res.json && res.json.message; + if (!detail) { + // translators: %s: an HTTP status code, such as 500. + detail = sprintf(__('GitHub returned %s'), res.status); + } + return { + ok: false, + reason: classifyFailure(res), + error: sprintf( + // translators: 1: what failed, such as "Could not create the branch". 2: GitHub's reason, which is in English. 3: technical details in square brackets, for GitHub support. + __('%1$s: %2$s%3$s'), + what, + detail, + describeResponse(res) + ) + }; } /** @@ -276,7 +291,12 @@ async function ensureFork({ token, login }, deps = {}) { const notAFork = () => ({ ok: false, reason: 'error', - error: `You already have a repository named ${up.repo} that is not a fork of ${up.owner}/${up.repo}, so there is nowhere to push this. The patch file still works.` + error: sprintf( + // translators: 1: a repository name, such as wordpress-develop. 2: the repository it should be a fork of, such as WordPress/wordpress-develop. + __('You already have a repository named %1$s that is not a fork of %2$s, so there is nowhere to push this. The patch file still works.'), + up.repo, + `${up.owner}/${up.repo}` + ) }); // Ready means the fork's own trunk ref answers — not that the repo @@ -295,7 +315,7 @@ async function ensureFork({ token, login }, deps = {}) { return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }; } if (existing.status === 200 && !isOurFork(existing.json)) return notAFork(); - if (existing.status !== 200 && existing.status !== 404) return failure(existing, 'Could not check for your fork'); + if (existing.status !== 200 && existing.status !== 404) return failure(existing, __('Could not check for your fork')); let created = false; if (existing.status === 404) { @@ -305,7 +325,11 @@ async function ensureFork({ token, login }, deps = {}) { } catch (e) { return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }; } - if (forked.status !== 202 && forked.status !== 200) return failure(forked, `Could not fork ${up.repo}`); + if (forked.status !== 202 && forked.status !== 200) return failure(forked, sprintf( + // translators: %s: a repository name, such as wordpress-develop. + __('Could not fork %s'), + up.repo + )); created = true; } @@ -319,7 +343,7 @@ async function ensureFork({ token, login }, deps = {}) { // 404 is the fork still initialising, the state this loop exists to // wait out. Anything else — a token revoked mid-wait, a spent rate // limit — will not resolve by waiting, so it is reported as itself. - if (ref.status !== 404) return failure(ref, 'Could not read your fork'); + if (ref.status !== 404) return failure(ref, __('Could not read your fork')); await wait(FORK_POLL_INTERVAL_MS); } } catch (e) { @@ -329,7 +353,7 @@ async function ensureFork({ token, login }, deps = {}) { return { ok: false, reason: 'error', - error: 'Your fork is still being set up on GitHub — for a repository this size that can take a few minutes. Try again shortly; the patch file still works.' + error: __('Your fork is still being set up on GitHub — for a repository this size that can take a few minutes. Try again shortly; the patch file still works.') }; } @@ -374,7 +398,11 @@ async function resolveBase({ token, login, baseSha }, deps = {}) { const ref = await get(`${repo}/git/ref/heads/${base}`, { token }); if (ref.status !== 200 || !ref.json || !ref.json.object || !ref.json.object.sha) { - return failure(ref, `Could not read your fork’s ${base}`); + return failure(ref, sprintf( + // translators: %s: a branch name, such as trunk. + __('Could not read your fork’s %s'), + base + )); } const tip = String(ref.json.object.sha); return { ok: true, sha: tip, exact: tip === baseSha }; @@ -475,13 +503,17 @@ async function createTree({ token, login, baseTreeSha, files }, deps = {}) { encoding: 'base64' }, { token }); if (blob.status !== 201 || !blob.json || !blob.json.sha) { - return failure(blob, `Could not upload ${file.path}`); + return failure(blob, sprintf( + // translators: %s: the path of a file in the change. + __('Could not upload %s'), + file.path + )); } entries.push({ path: file.path, mode: file.mode || DEFAULT_MODE, type: 'blob', sha: blob.json.sha }); } const tree = await post(`${repo}/git/trees`, { base_tree: baseTreeSha, tree: entries }, { token }); - if (tree.status !== 201 || !tree.json || !tree.json.sha) return failure(tree, 'Could not assemble the change'); + if (tree.status !== 201 || !tree.json || !tree.json.sha) return failure(tree, __('Could not assemble the change')); return { ok: true, sha: String(tree.json.sha) }; } catch (e) { return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }; @@ -514,7 +546,7 @@ async function commitAndBranch({ token, login, ticketId, message, treeSha, paren tree: treeSha, parents: [parentSha] }, { token }); - if (commit.status !== 201 || !commit.json || !commit.json.sha) return failure(commit, 'Could not create the commit'); + if (commit.status !== 201 || !commit.json || !commit.json.sha) return failure(commit, __('Could not create the commit')); const sha = String(commit.json.sha); let lastRes = null; @@ -530,16 +562,20 @@ async function commitAndBranch({ token, login, ticketId, message, treeSha, paren return { ok: false, reason: 'error', - error: `Your fork is still being set up on GitHub. Try again in a minute — the patch file still works.${describeResponse(ref)}` + error: sprintf( + // translators: %s: technical details in square brackets, for GitHub support. + __('Your fork is still being set up on GitHub. Try again in a minute — the patch file still works.%s'), + describeResponse(ref) + ) }; } // 422 is how GitHub says the reference already exists — the only // status worth another name. Anything else is a real failure and // retrying it nine more times would only slow down the report. - if (ref.status !== 422) return failure(ref, 'Could not create the branch'); + if (ref.status !== 422) return failure(ref, __('Could not create the branch')); lastRes = ref; } - return failure(lastRes, 'Could not find an unused branch name'); + return failure(lastRes, __('Could not find an unused branch name')); } catch (e) { return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }; } @@ -568,7 +604,7 @@ async function createPullRequest({ token, login, branch, title, body }, deps = { base: baseBranchFor(deps.project), maintainer_can_modify: true }, { token }); - if (res.status !== 201 || !res.json || !res.json.html_url) return failure(res, 'Could not open the pull request'); + if (res.status !== 201 || !res.json || !res.json.html_url) return failure(res, __('Could not open the pull request')); return { ok: true, url: String(res.json.html_url), number: Number(res.json.number) }; } catch (e) { return { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }; @@ -609,7 +645,7 @@ async function openPullRequest({ token, login, ticketId, baseSha, files, title, const at = (stage, result) => ({ ...result, stage }); if (!files || files.length === 0) { - return { ok: false, reason: 'empty', error: 'There are no changes to open a pull request with.', stage: 'collect' }; + return { ok: false, reason: 'empty', error: __('There are no changes to open a pull request with.'), stage: 'collect' }; } report('forking'); @@ -633,7 +669,15 @@ async function openPullRequest({ token, login, ticketId, baseSha, files, title, return { ok: false, reason: 'stale', - error: `Trunk has moved under ${stale.clashes.length === 1 ? 'a file you edited' : 'files you edited'} (${stale.clashes.slice(0, 3).join(', ')}${stale.clashes.length > 3 ? ', …' : ''}). Update this site to the latest trunk, check your changes still apply, and try again.`, + error: sprintf( + // translators: %s: up to three file paths, separated by commas. + _n( + 'Trunk has moved under a file you edited (%s). Update this site to the latest trunk, check your changes still apply, and try again.', + 'Trunk has moved under files you edited (%s). Update this site to the latest trunk, check your changes still apply, and try again.', + stale.clashes.length + ), + stale.clashes.slice(0, 3).join(', ') + (stale.clashes.length > 3 ? ', …' : '') + ), stage: 'syncing' }; } @@ -649,7 +693,7 @@ async function openPullRequest({ token, login, ticketId, baseSha, files, title, return at('syncing', { ok: false, reason: 'offline', error: String(e && e.message ? e.message : e) }); } if (baseCommit.status !== 200 || !baseCommit.json || !baseCommit.json.tree) { - return at('syncing', failure(baseCommit, 'Could not read the base commit')); + return at('syncing', failure(baseCommit, __('Could not read the base commit'))); } report('committing'); diff --git a/src/main.js b/src/main.js index c0730cde..d3b5424e 100644 --- a/src/main.js +++ b/src/main.js @@ -1291,7 +1291,7 @@ ipcMain.handle('github:sign-in', async (event) => { ipcMain.handle('github:open-pr', async (event, sitePath, options = {}) => { if (!githubToken || !githubLogin) { - return { ok: false, reason: 'unauthorized', error: 'Sign in to GitHub first.', stage: 'auth' }; + return { ok: false, reason: 'unauthorized', error: __('Sign in to GitHub first.'), stage: 'auth' }; } // The ticket is read from this site's stored metadata rather than taken @@ -1307,7 +1307,7 @@ ipcMain.handle('github:open-pr', async (event, sitePath, options = {}) => { const project = projectTypeForSite(meta); const ticketId = meta.tracTicket; if (!ticketId) { - return { ok: false, reason: 'no-ticket', error: `Link a ${project.workItem.label} to this site first. A pull request has to cite one.`, stage: 'auth' }; + return { ok: false, reason: 'no-ticket', error: project.cards.prNeedsWorkItem, stage: 'auth' }; } const ownershipRefusal = await appliedPatchSubmissionRefusal(sitePath); if (ownershipRefusal) return { ...ownershipRefusal, stage: 'ownership' }; diff --git a/src/project-type.cjs b/src/project-type.cjs index 77f33bdd..bd40beb7 100644 --- a/src/project-type.cjs +++ b/src/project-type.cjs @@ -84,30 +84,35 @@ const PROJECT_TYPES = { cards: { // What the pull-request destination says when there is no work item // linked to cite. - prBlockedNote: 'No ticket is linked to this site. A pull request has to cite one — link it in the Trac card.', + get prBlockedNote() { return __('No ticket is linked to this site. A pull request has to cite one — link it in the Trac card.'); }, // The rest of the pull-request destination's words that differ per // target: what it costs, what happens after, what the app cannot do // for a signed-out contributor, the help under the notes field, the // line that sends them back to the work item once the pull request // exists, and the fold that says how pull requests work here. - prCost: 'A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.', - prAfter: 'Automated checks run on it. Nobody watches GitHub, though — posting the link on the ticket is what gets it seen.', - signInCannot: 'It cannot create the GitHub account for you, and it cannot post to Trac on your behalf.', - prNotesHelp: 'Goes at the top of the description. The ticket link and your WordPress.org username are added underneath.', - prLoopBack: 'Triage and props live on the ticket, so the link belongs there too.', + get prCost() { return __('A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.'); }, + get prAfter() { return __('Automated checks run on it. Nobody watches GitHub, though — posting the link on the ticket is what gets it seen.'); }, + get signInCannot() { return __('It cannot create the GitHub account for you, and it cannot post to Trac on your behalf.'); }, + get prNotesHelp() { return __('Goes at the top of the description. The ticket link and your WordPress.org username are added underneath.'); }, + get prLoopBack() { return __('Triage and props live on the ticket, so the link belongs there too.'); }, // Two facts from the core handbook that a first-timer has no way to // know and that change what they do next: nobody is watching GitHub, // and nothing is merged there. Both make the Trac step this flow // ends on the point rather than the postscript. - prHow: { - summary: 'How pull requests work in core', - lines: [ - 'Nobody watches the pull request list. Yours is seen because its link is on the ticket — which is why this flow ends by sending you back there.', - 'Nothing is merged on GitHub either. A committer applies the change themselves, and the ticket is where they decide to.' - ], - linkLabel: 'The handbook page on pull requests', - linkUrl: 'https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/' + get prHow() { + return { + summary: __('How pull requests work in core'), + lines: [ + __('Nobody watches the pull request list. Yours is seen because its link is on the ticket — which is why this flow ends by sending you back there.'), + __('Nothing is merged on GitHub either. A committer applies the change themselves, and the ticket is where they decide to.') + ], + linkLabel: __('The handbook page on pull requests'), + linkUrl: 'https://make.wordpress.org/core/handbook/contribute/git/github-pull-requests-for-code-review/' + }; }, + // Main's refusal to open a pull request with nothing linked, for a + // caller that skipped the card's own note above. + get prNeedsWorkItem() { return __('Link a Trac ticket to this site first. A pull request has to cite one.'); }, // Where a contributor edits, named by the hint under the terminal // ("Edited files in src/? Run npm run build"). Core's source is // src/; Gutenberg's is its packages (#490). @@ -184,24 +189,27 @@ const PROJECT_TYPES = { }, cards: { - prBlockedNote: 'No issue is linked to this site. A pull request has to cite one: link it in the GitHub issue card.', - prCost: 'A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.', - prAfter: 'Automated checks run on it, and it is reviewed and merged right there: on Gutenberg the pull request is the venue.', - signInCannot: 'It cannot create the GitHub account for you.', - prNotesHelp: 'Goes at the top of the description. The Fixes line that links the issue and your WordPress.org username are added underneath.', - prLoopBack: 'The Fixes line already lists it on the issue. A comment there still tells the people watching it.', + get prBlockedNote() { return __('No issue is linked to this site. A pull request has to cite one: link it in the GitHub issue card.'); }, + get prCost() { return __('A GitHub account. The fork is made for you; no password is typed into this app and no credential is written to disk.'); }, + get prAfter() { return __('Automated checks run on it, and it is reviewed and merged right there: on Gutenberg the pull request is the venue.'); }, + get signInCannot() { return __('It cannot create the GitHub account for you.'); }, + get prNotesHelp() { return __('Goes at the top of the description. The Fixes line that links the issue and your WordPress.org username are added underneath.'); }, + get prLoopBack() { return __('The Fixes line already lists it on the issue. A comment there still tells the people watching it.'); }, // The two facts above are false here, and the audience least able // to spot the app describing a different project is exactly this // one, so the fold says what is true of Gutenberg instead. - prHow: { - summary: 'How pull requests work in Gutenberg', - lines: [ - 'The pull request is where the change is reviewed and, once approved, merged. Nothing has to be posted anywhere else for it to be seen.', - 'Reviewers ask for testing steps. Put them in the notes: what to open, what to click, what should happen.' - ], - linkLabel: 'The Gutenberg contributing guide', - linkUrl: 'https://github.com/WordPress/gutenberg/blob/trunk/CONTRIBUTING.md' + get prHow() { + return { + summary: __('How pull requests work in Gutenberg'), + lines: [ + __('The pull request is where the change is reviewed and, once approved, merged. Nothing has to be posted anywhere else for it to be seen.'), + __('Reviewers ask for testing steps. Put them in the notes: what to open, what to click, what should happen.') + ], + linkLabel: __('The Gutenberg contributing guide'), + linkUrl: 'https://github.com/WordPress/gutenberg/blob/trunk/CONTRIBUTING.md' + }; }, + get prNeedsWorkItem() { return __('Link a GitHub issue to this site first. A pull request has to cite one.'); }, sourceDir: 'packages/', patchFiles: false }, diff --git a/src/renderer/components/pull-request-destination.jsx b/src/renderer/components/pull-request-destination.jsx index 31cd1fce..d00ff49f 100644 --- a/src/renderer/components/pull-request-destination.jsx +++ b/src/renderer/components/pull-request-destination.jsx @@ -1,4 +1,6 @@ import { Button, Spinner, TextControl, TextareaControl } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; import { Notice, Stack, Text } from '@wordpress/ui'; import { copy as copyIcon, check as checkIcon } from '@wordpress/icons'; import { prStageLabel } from '../pr-stage.cjs'; @@ -8,12 +10,15 @@ import { Destination } from './destination.jsx'; // these still leaves the patch file, which is what the card offers underneath. // The no-ticket refusal is not here: the main process words it for the site's // work item (#251), and the fallback below shows that sentence as sent. -const PR_FAILURE_MESSAGES = { - unauthorized: 'That GitHub sign-in is no longer valid. Sign in again, or save the patch file instead.', - 'rate-limited': 'GitHub is rate-limiting this connection. It usually clears within the hour.', - offline: 'No connection to GitHub.', - empty: 'There are no changes to open a pull request with.' -}; +function prFailureMessage(reason) { + switch (reason) { + case 'unauthorized': return __('That GitHub sign-in is no longer valid. Sign in again, or save the patch file instead.'); + case 'rate-limited': return __('GitHub is rate-limiting this connection. It usually clears within the hour.'); + case 'offline': return __('No connection to GitHub.'); + case 'empty': return __('There are no changes to open a pull request with.'); + default: return null; + } +} // A notice here is not also spoken: the card it is in is being read. const SILENT = ''; @@ -53,12 +58,25 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, */} {pr.result.dryRun ? ( - Dry run — branch was created on your fork; no pull request was opened. + {createInterpolateElement( + // translators: is the name of the branch pushed to the contributor's fork, as a link. + __('Dry run — branch was created on your fork; no pull request was opened.'), + { branch: } + )} ) : ( - Opened - {' '}from {pr.result.branch}. + {createInterpolateElement( + sprintf( + // translators: %s: the number of the pull request, inside a link to it. is the name of the branch it was opened from. + __('Opened pull request #%s from .'), + pr.result.number + ), + { + link: {ticket ? ( ) : null} @@ -100,13 +122,13 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, // Not yet asked, which is not the same as signed out: offering "Sign in" // before the answer arrives makes the card flicker on every open. if (account === null) { - return Checking…; + return {__('Checking…')}; } if (account.configured === false) { return ( - This build has no GitHub application configured, so it cannot open a pull request. The other destinations still work. + {__('This build has no GitHub application configured, so it cannot open a pull request. The other destinations still work.')} ); } @@ -115,17 +137,21 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, return ( <> - Enter this code at github.com/login/device, which has been opened in your browser. + {createInterpolateElement( + // translators:
is GitHub's sign-in page, github.com/login/device. + __('Enter this code at
, which has been opened in your browser.'), + { strong: , address: <>github.com/login/device } + )}
{pr.deviceCode.userCode}
- Waiting for you to finish in the browser… + {__('Waiting for you to finish in the browser…')} - + ); } @@ -147,13 +173,17 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, value={pr.title} onChange={pr.setTitle} disabled={Boolean(pr.stage)} - placeholder="Reject a theme zip in the plugin installer" - label="Title" - help="What the change does, in one line. Reviewers scan these." + placeholder={__('Reject a theme zip in the plugin installer')} + label={__('Title')} + help={__('What the change does, in one line. Reviewers scan these.')} /> {!pr.title.trim() ? ( - Left empty, it will be titled {workItem.defaultPrTitle(ticket)}. + {createInterpolateElement( + // translators: is the title an untitled pull request gets, such as "Ticket #60001". + __('Left empty, it will be titled <strong><title /></strong>.'), + { strong: <strong />, title: <>{workItem.defaultPrTitle(ticket)}</> } + )} </Text> ) : null} {/* @@ -167,8 +197,12 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, onChange={pr.setNotes} disabled={Boolean(pr.stage)} rows={4} - label="Notes for reviewers (optional)" - placeholder={'What the change does, and why.\nHow to see it working — the steps you used.\nAnything you are unsure about.'} + label={__('Notes for reviewers (optional)')} + placeholder={[ + __('What the change does, and why.'), + __('How to see it working — the steps you used.'), + __('Anything you are unsure about.') + ].join('\n')} help={project.cards.prNotesHelp} /> {/* @@ -197,7 +231,7 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, onClick={pr.open} isBusy={Boolean(pr.stage)} disabled={Boolean(pr.stage)} - >{account?.testMode?.dryRun ? 'Push branch (dry run)' : 'Open pull request'}</Button> + >{account?.testMode?.dryRun ? __('Push branch (dry run)') : __('Open pull request')}</Button> </> ) : ( <Text variant="body-sm" className="muted-label">{project.cards.prBlockedNote}</Text> @@ -216,12 +250,22 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, you" answers what, this answers where — which account the fork and the branch land in. */} - Signed in as {account.login} — the fork and branch go to{' '} - <Button - variant="link" - onClick={()=>window.api.openExternal(`https://github.com/${account.login}/${project.upstream.repo}`)} - >{account.login}/{project.upstream.repo}</Button>.{' '} - <Button variant="link" onClick={pr.signOut}>Sign out</Button> + <span> + {createInterpolateElement( + // translators: <login /> is the contributor's GitHub username. <fork /> is their fork, such as janedoe/wordpress-develop, as a link. + __('Signed in as <login /> — the fork and branch go to <fork />.'), + { + login: <>{account.login}</>, + fork: ( + <Button + variant="link" + onClick={()=>window.api.openExternal(`https://github.com/${account.login}/${project.upstream.repo}`)} + >{`${account.login}/${project.upstream.repo}`}</Button> + ) + } + )} + </span>{' '} + <Button variant="link" onClick={pr.signOut}>{__('Sign out')}</Button> </Text> )} </> @@ -232,9 +276,9 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, return ( <> <Text variant="body-sm" className="muted-label"> - Nothing was signed in and nothing was sent. The patch file is still yours to save, and the other destinations are unchanged. + {__('Nothing was signed in and nothing was sent. The patch file is still yours to save, and the other destinations are unchanged.')} </Text> - <Text variant="body-sm"><Button variant="link" onClick={pr.askAgain}>Show this again</Button></Text> + <Text variant="body-sm"><Button variant="link" onClick={pr.askAgain}>{__('Show this again')}</Button></Text> </> ); } @@ -247,18 +291,22 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, cliff is sprung rather than named. */} <Text variant="body-sm"> - Signing in lets the app fork {project.upstream.repo} to your account, push this patch to a branch there, and open the pull request. It signs you in through your browser, never asks for your password, and forgets the authorization when you quit. + {sprintf( + // translators: %s: the repository the app forks, such as wordpress-develop. + __('Signing in lets the app fork %s to your account, push this patch to a branch there, and open the pull request. It signs you in through your browser, never asks for your password, and forgets the authorization when you quit.'), + project.upstream.repo + )} </Text> <Text variant="body-sm" className="muted-label">{project.cards.signInCannot}</Text> - <Button variant="primary" onClick={pr.startSignIn}>Sign in with GitHub</Button> - <Text variant="body-sm"><Button variant="link" onClick={pr.decline}>Not now</Button></Text> + <Button variant="primary" onClick={pr.startSignIn}>{__('Sign in with GitHub')}</Button> + <Text variant="body-sm"><Button variant="link" onClick={pr.decline}>{__('Not now')}</Button></Text> </> ); }; return ( <Destination - title="Open a pull request" + title={__('Open a pull request')} cost={project.cards.prCost} after={project.cards.prAfter} > @@ -271,11 +319,15 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, */} {account?.testMode ? ( <Notice.Root intent="neutral" spokenMessage={SILENT}> - <Notice.Title>Test mode</Notice.Title> + <Notice.Title>{__('Test mode')}</Notice.Title> <Notice.Description> {account.testMode.dryRun - ? 'Dry run — a branch is pushed to your fork, no pull request is opened.' - : <>Pull requests go to <code>{account.testMode.target}</code>, not to {upstreamPath}.</>} + ? __('Dry run — a branch is pushed to your fork, no pull request is opened.') + : createInterpolateElement( + // translators: <target /> is the sandbox repository pull requests are redirected to. <upstream /> is the repository they would otherwise go to, such as WordPress/wordpress-develop. + __('Pull requests go to <target />, not to <upstream />.'), + { target: <code>{account.testMode.target}</code>, upstream: <>{upstreamPath}</> } + )} </Notice.Description> </Notice.Root> ) : null} @@ -284,13 +336,13 @@ export function PullRequestDestination({ pr, project, workItem, ticket, refusal, {pr.error ? ( <> <Text variant="body-sm" className="problem-text" role="alert"> - {PR_FAILURE_MESSAGES[pr.error.reason] || pr.error.error} + {prFailureMessage(pr.error.reason) || pr.error.error} </Text> {/* Every failure lands here, and every failure has the same floor: the file exists regardless of what GitHub did. */} - <Button variant="secondary" onClick={onSavePatch}>Save the patch file instead</Button> + <Button variant="secondary" onClick={onSavePatch}>{__('Save the patch file instead')}</Button> </> ) : null} </Destination> diff --git a/src/renderer/hooks/use-pull-request.jsx b/src/renderer/hooks/use-pull-request.jsx index fe95566f..6eb94e09 100644 --- a/src/renderer/hooks/use-pull-request.jsx +++ b/src/renderer/hooks/use-pull-request.jsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { __ } from '@wordpress/i18n'; import { carryTestMode } from '../github-account.cjs'; import { prConfirmationMessage } from '../confirmations.cjs'; @@ -75,15 +76,15 @@ export function usePullRequest({ sitePath, confirm }) { } // Declining is a choice, not a fault, so it reads as one. setSignInError(done && done.reason === 'denied' - ? 'The authorization was declined on GitHub. Nothing was changed.' - : (done && done.error) || 'Sign-in did not complete.'); + ? __('The authorization was declined on GitHub. Nothing was changed.') + : (done && done.error) || __('Sign-in did not complete.')); }); } catch (e) { setSignInError(e && e.message ? e.message : String(e)); return; } if (!started || !started.ok) { - setSignInError((started && started.error) || 'Could not start sign-in.'); + setSignInError((started && started.error) || __('Could not start sign-in.')); return; } setDeviceCode({ userCode: started.userCode, verificationUri: started.verificationUri }); @@ -140,7 +141,7 @@ export function usePullRequest({ sitePath, confirm }) { // contributor who looked away during the slow fork step (#253). confirm(prConfirmationMessage(res)); } else { - setError(res || { reason: 'error', error: 'The pull request could not be opened.' }); + setError(res || { reason: 'error', error: __('The pull request could not be opened.') }); // A revoked authorization is forgotten in the main process, so the card // has to stop claiming an account it no longer has. if (res && res.reason === 'unauthorized') setAccount((prev) => carryTestMode(prev, { login: null, configured: true })); diff --git a/src/renderer/pr-stage.cjs b/src/renderer/pr-stage.cjs index 99a5ebcc..a8ed766f 100644 --- a/src/renderer/pr-stage.cjs +++ b/src/renderer/pr-stage.cjs @@ -1,5 +1,7 @@ 'use strict'; +const { __, sprintf } = require('@wordpress/i18n'); + /** * What the card says while a pull request is being opened (#167). Each step is * named because they take visibly different amounts of time: forking is the @@ -18,11 +20,13 @@ */ function prStageLabel(stage, repoPath) { switch (stage) { - case 'forking': return `Creating your fork of ${repoPath}…`; - case 'syncing': return 'Bringing your fork up to date…'; - case 'committing': return 'Uploading your changes…'; - case 'opening': return 'Opening the pull request…'; - default: return 'Working…'; + case 'forking': + // translators: %s: the repository being forked, such as WordPress/gutenberg. + return sprintf(__('Creating your fork of %s…'), repoPath); + case 'syncing': return __('Bringing your fork up to date…'); + case 'committing': return __('Uploading your changes…'); + case 'opening': return __('Opening the pull request…'); + default: return __('Working…'); } } diff --git a/tests/e2e/journeys/i18n.spec.js b/tests/e2e/journeys/i18n.spec.js index 3883c1b6..bfd4e34a 100644 --- a/tests/e2e/journeys/i18n.spec.js +++ b/tests/e2e/journeys/i18n.spec.js @@ -670,3 +670,159 @@ for ( const [ named, label ] of [ [ 'by its name', '60001.diff' ], [ 'with no na expect( await unwrappedInReview( dialog ) ).toEqual( [] ); } ); } + +test( 'the Open a pull request card is fully translatable, from signing in to the pull request it opened', async ( { session } ) => { + // GitHub is answered at the IPC seam, as in open-pull-request.spec.js. + // What is GitHub's stays as it is and is left out of the scan: the code + // to type, the fork and the branch, each of which is its own element. + const CODE = 'ABCD-1234'; + const BRANCH = 'trac-60001'; + const THEIRS = [ CODE, 'janedoe/wordpress-develop', BRANCH ]; + const site = await makeSite( session ); + const { app, page } = await session.start( site.settings, { lang: 'en-XA' } ); + await app.evaluate( ( { ipcMain } ) => { + global.__e2eLogin = null; + global.__e2ePr = null; + const answers = { + 'git:list-ticket-patches': () => ( { ok: true, prs: { status: 'ok', items: [] } } ), + 'trac:list-attachments': () => ( { ok: true, status: 'ok', ticket: null, items: [] } ), + 'url:open': () => true, + 'github:account': () => ( { ok: true, login: global.__e2eLogin, configured: true, testMode: null } ), + 'github:sign-in': () => ( { ok: true, userCode: 'ABCD-1234', verificationUri: 'https://github.com/login/device' } ), + 'github:open-pr': () => global.__e2ePr, + }; + for ( const [ channel, answer ] of Object.entries( answers ) ) { + ipcMain.removeHandler( channel ); + ipcMain.handle( channel, answer ); + } + } ); + const answerPr = ( result ) => app.evaluate( ( _, value ) => { + global.__e2ePr = value; + }, result ); + + // A ticket, so the signed-in card offers its form. + const ticket = ui.workItemCard( page, pseudoLocalize( 'Trac ticket' ) ); + await ticket.getByLabel( pseudoLocalize( 'Ticket number or URL' ), { exact: true } ).fill( '60001' ); + await ticket.getByRole( 'button', { name: pseudoLocalize( 'Link ticket' ), exact: true } ).click(); + await expect( ticket.getByRole( 'button', { name: pseudoLocalize( 'Unlink' ), exact: true } ) ).toBeVisible( { timeout: 30_000 } ); + write( site.dir, LOGIN, '<?php // my fix\n' ); + + // The rest of the dialog is #624's: only the card is scanned, found by + // its heading. + await page.getByRole( 'button', { name: pseudoLocalize( 'Review & submit changes' ), exact: true } ).click(); + const card = page.getByRole( 'dialog' ).getByText( pseudoLocalize( 'Open a pull request' ), { exact: true } ).locator( '..' ); + const inCard = async () => ( await unwrapped( card ) ).filter( ( text ) => ! THEIRS.includes( text ) ); + const button = ( label ) => card.getByRole( 'button', { name: pseudoLocalize( label ), exact: true } ); + + // Signed out: the ask, then declining it, then asked again. + await expect( button( 'Sign in with GitHub' ) ).toBeVisible( { timeout: 30_000 } ); + expect( await inCard() ).toEqual( [] ); + await button( 'Not now' ).click(); + await expect( button( 'Show this again' ) ).toBeVisible(); + expect( await inCard() ).toEqual( [] ); + await button( 'Show this again' ).click(); + + // The code to type in the browser, and the wait for it. + await button( 'Sign in with GitHub' ).click(); + await expect( card.getByText( CODE, { exact: true } ) ).toBeVisible(); + await expect( card.getByText( pseudoLocalize( 'Waiting for you to finish in the browser…' ), { exact: true } ) ).toBeVisible(); + expect( await inCard() ).toEqual( [] ); + + // GitHub's answer arrives: signed in, with the form. + await app.evaluate( ( { BrowserWindow } ) => { + global.__e2eLogin = 'janedoe'; + BrowserWindow.getAllWindows()[ 0 ].webContents.send( 'github:sign-in:done', { ok: true, login: 'janedoe' } ); + } ); + await expect( button( 'Open pull request' ) ).toBeVisible(); + await expect( card.getByRole( 'button', { name: 'janedoe/wordpress-develop', exact: true } ) ).toBeVisible(); + await card.locator( 'summary' ).click(); + expect( await inCard() ).toEqual( [] ); + + // A failure the card words itself. + await answerPr( { ok: false, reason: 'rate-limited', error: 'not shown' } ); + await button( 'Open pull request' ).click(); + await expect( card.getByText( pseudoLocalize( 'GitHub is rate-limiting this connection. It usually clears within the hour.' ), { exact: true } ) ).toBeVisible(); + await expect( button( 'Save the patch file instead' ) ).toBeVisible(); + expect( await inCard() ).toEqual( [] ); + + // A dry run's result. + await answerPr( { ok: true, dryRun: true, url: `https://github.com/janedoe/wordpress-develop/tree/${ BRANCH }`, branch: BRANCH } ); + await button( 'Open pull request' ).click(); + await expect( card.getByRole( 'button', { name: BRANCH, exact: true } ) ).toBeVisible(); + expect( await inCard() ).toEqual( [] ); + + // A pull request opened from a checkout that was behind trunk. A result + // stays until the dialog opens again, so it is closed and opened first: + // by its Close button, since the button pressed is gone and took the + // focus with it. + await page.getByRole( 'dialog' ).getByRole( 'button', { name: pseudoLocalize( 'Close' ), exact: true } ).click(); + await expect( page.getByRole( 'dialog' ) ).toHaveCount( 0 ); + await page.getByRole( 'button', { name: pseudoLocalize( 'Review & submit changes' ), exact: true } ).click(); + await answerPr( { ok: true, url: 'https://github.com/WordPress/wordpress-develop/pull/9', number: 9, branch: BRANCH, exactBase: false } ); + await button( 'Open pull request' ).click(); + await expect( button( 'Copy the link' ) ).toBeVisible(); + await expect( card.getByRole( 'button', { name: pseudoLocalize( 'Open #%s to comment' ).replace( '%s', '60001' ), exact: true } ) ).toBeVisible(); + // The link in the sentence is part of it: the scan stops at the link, so + // the sentence around it is checked whole and the link left out. + const opened = card.locator( '.success-text' ); + await expect( opened ).toHaveText( /^\[.*\]$/ ); + const link = ( await opened.getByRole( 'button' ).textContent() ).trim(); + expect( ( await inCard() ).filter( ( text ) => text !== link ) ).toEqual( [] ); +} ); + +test( 'the Open a pull request card on a Gutenberg site is fully translatable, in the words that are Gutenberg\'s own', async ( { session } ) => { + // The card's words that differ by project: the ask when signed out, the + // notes help and the fold with the form, and the loop-back once the pull + // request exists. The issue is linked in the site's record rather than + // through its card, which is the Core test's. + const ISSUE = '71234'; + const BRANCH = `fix/issue-${ ISSUE }`; + const THEIRS = [ 'janedoe/gutenberg', BRANCH ]; + const site = await makeSite( session ); + Object.assign( site.settings.siteMeta[ site.dir ], { projectType: 'gutenberg', tracTicket: ISSUE } ); + write( site.dir, LOGIN, '<?php // my fix\n' ); + const { app, page } = await session.start( site.settings, { lang: 'en-XA' } ); + await app.evaluate( ( { ipcMain } ) => { + global.__e2eLogin = null; + const answers = { + 'git:list-ticket-patches': () => ( { ok: true, prs: { status: 'ok', items: [] } } ), + 'url:open': () => true, + 'github:account': () => ( { ok: true, login: global.__e2eLogin, configured: true, testMode: null } ), + 'github:sign-in': () => ( { ok: true, userCode: 'ABCD-1234', verificationUri: 'https://github.com/login/device' } ), + 'github:open-pr': () => ( { ok: true, url: 'https://github.com/WordPress/gutenberg/pull/9', number: 9, branch: 'fix/issue-71234', exactBase: true } ), + }; + for ( const [ channel, answer ] of Object.entries( answers ) ) { + ipcMain.removeHandler( channel ); + ipcMain.handle( channel, answer ); + } + } ); + await page.getByRole( 'button', { name: pseudoLocalize( 'Review & submit changes' ), exact: true } ).click(); + const card = page.getByRole( 'dialog' ).getByText( pseudoLocalize( 'Open a pull request' ), { exact: true } ).locator( '..' ); + const inCard = async () => ( await unwrapped( card ) ).filter( ( text ) => ! THEIRS.includes( text ) ); + const button = ( label ) => card.getByRole( 'button', { name: pseudoLocalize( label ), exact: true } ); + + // Signed out: what the app cannot do for a contributor on GitHub. + await expect( card.getByText( pseudoLocalize( 'It cannot create the GitHub account for you.' ), { exact: true } ) ).toBeVisible( { timeout: 30_000 } ); + expect( await inCard() ).toEqual( [] ); + + // Signed in, with the form and its fold open. + await button( 'Sign in with GitHub' ).click(); + await expect( card.getByText( 'ABCD-1234', { exact: true } ) ).toBeVisible(); + await app.evaluate( ( { BrowserWindow } ) => { + global.__e2eLogin = 'janedoe'; + BrowserWindow.getAllWindows()[ 0 ].webContents.send( 'github:sign-in:done', { ok: true, login: 'janedoe' } ); + } ); + await expect( button( 'Open pull request' ) ).toBeVisible(); + await card.locator( 'summary' ).click(); + await expect( card.getByText( pseudoLocalize( 'How pull requests work in Gutenberg' ), { exact: true } ) ).toBeVisible(); + await expect( card.getByText( pseudoLocalize( 'Goes at the top of the description. The Fixes line that links the issue and your WordPress.org username are added underneath.' ), { exact: true } ) ).toBeVisible(); + expect( await inCard() ).toEqual( [] ); + + // The pull request it opened, and the line back to the issue. + await button( 'Open pull request' ).click(); + await expect( card.getByText( pseudoLocalize( 'The Fixes line already lists it on the issue. A comment there still tells the people watching it.' ), { exact: true } ) ).toBeVisible(); + const opened = card.locator( '.success-text' ); + await expect( opened ).toHaveText( /^\[.*\]$/ ); + const link = ( await opened.getByRole( 'button' ).textContent() ).trim(); + expect( ( await inCard() ).filter( ( text ) => text !== link ) ).toEqual( [] ); +} ); diff --git a/tests/unit/github-auth.test.cjs b/tests/unit/github-auth.test.cjs index 6e0af17c..cd044cd3 100644 --- a/tests/unit/github-auth.test.cjs +++ b/tests/unit/github-auth.test.cjs @@ -12,6 +12,8 @@ const test = require('node:test'); const assert = require('node:assert'); const { requestDeviceCode, pollForToken, fetchViewer, getClientId } = require('../../src/github-auth.cjs'); +const { addFilter, removeFilter } = require('@wordpress/hooks'); +const { pseudoLocalize } = require('../../src/renderer/pseudo-locale.cjs'); const CLIENT_ID = 'Ov23liTEST'; @@ -228,3 +230,14 @@ test('fetchViewer refuses a token whose granted scopes cannot push', async () => // No header is no evidence — some token types omit it entirely. assert.strictEqual((await viewer(undefined)).ok, true); }); + +test('a refused scope is said in the locale main applied, around the names it keeps', async (t) => { + // main applies the locale at startup; a message built at require time + // would stay English. + addFilter('i18n.gettext', 'test/pseudo-locale', (text) => pseudoLocalize(text)); + t.after(() => removeFilter('i18n.gettext', 'test/pseudo-locale')); + const res = await fetchViewer('gho_test', { get: async () => ({ status: 200, headers: { 'x-oauth-scopes': '' }, json: { login: 'janedoe' } }) }); + assert.match(res.error, /^\[.*\]$/); + assert.ok(res.error.includes('"WordPress Contributor Toolkit"'), res.error); + assert.ok(res.error.includes('github.com → Settings → Applications'), res.error); +}); diff --git a/tests/unit/github-pr.test.cjs b/tests/unit/github-pr.test.cjs index eed5d5c3..3e284dae 100644 --- a/tests/unit/github-pr.test.cjs +++ b/tests/unit/github-pr.test.cjs @@ -31,6 +31,8 @@ const { openPullRequest } = require('../../src/github-pr.cjs'); const { citesWorkItemFor } = require('../../src/patch-sources.cjs'); +const { addFilter, removeFilter } = require('@wordpress/hooks'); +const { pseudoLocalize } = require('../../src/renderer/pseudo-locale.cjs'); const TOKEN = 'gho_test'; const LOGIN = 'janedoe'; @@ -835,3 +837,39 @@ test('openPullRequest refuses an empty change before it touches GitHub', async ( assert.strictEqual(res.reason, 'empty'); assert.deepStrictEqual(api.calls, []); }); + +test('main’s failures are said in the locale it applied: the step, the plural and the sentence around GitHub’s words', async (t) => { + addFilter('i18n.gettext', 'test/pseudo-locale', (text) => pseudoLocalize(text)); + addFilter('i18n.ngettext', 'test/pseudo-locale', (text) => pseudoLocalize(text)); + t.after(() => { + removeFilter('i18n.gettext', 'test/pseudo-locale'); + removeFilter('i18n.ngettext', 'test/pseudo-locale'); + }); + + // A step that failed, composed with GitHub's reason, which stays as + // GitHub wrote it. + const branch = await commitAndBranch({ token: TOKEN, login: LOGIN, ticketId: 1, message: 'm', treeSha: 't', parentSha: 'p' }, router({ + 'POST git/commits': { status: 201, json: { sha: 'commit1' } }, + 'POST git/refs': { status: 403, headers: { 'x-ratelimit-remaining': '0' }, json: { message: 'API rate limit exceeded' } } + })); + assert.match(branch.error, /^\[/); + assert.ok(branch.error.includes(pseudoLocalize('Could not create the branch')), branch.error); + assert.ok(branch.error.includes('API rate limit exceeded'), branch.error); + + // Two files under a moved trunk take the plural. + const stale = await openPullRequest({ + token: TOKEN, login: LOGIN, ticketId: 1, baseSha: 'abc123', + files: [ + { path: 'a.php', kind: 'modify', content: Buffer.from('x'), mode: '100644', baseBlobSha: 'blob-old' }, + { path: 'b.php', kind: 'modify', content: Buffer.from('y'), mode: '100644', baseBlobSha: 'blob-old' } + ], + title: 't', body: 'b' + }, router({ + ...happyPathRoutes(), + [`GET ${FORK_URL}/git/ref/heads/trunk`]: { status: 200, json: { object: { sha: 'newer-tip' } } }, + 'GET contents/a.php?ref=newer-tip': { status: 200, json: { sha: 'blob-upstream' } }, + 'GET contents/b.php?ref=newer-tip': { status: 200, json: { sha: 'blob-upstream' } } + })); + assert.strictEqual(stale.reason, 'stale'); + assert.strictEqual(stale.error, pseudoLocalize('Trunk has moved under files you edited (%s). Update this site to the latest trunk, check your changes still apply, and try again.').replace('%s', 'a.php, b.php')); +}); diff --git a/tests/unit/pr-stage.test.cjs b/tests/unit/pr-stage.test.cjs index 308ed741..45b14f9e 100644 --- a/tests/unit/pr-stage.test.cjs +++ b/tests/unit/pr-stage.test.cjs @@ -3,6 +3,8 @@ const test = require('node:test'); const assert = require('node:assert'); const { prStageLabel } = require('../../src/renderer/pr-stage.cjs'); +const { addFilter, removeFilter } = require('@wordpress/hooks'); +const { pseudoLocalize } = require('../../src/renderer/pseudo-locale.cjs'); // The slow step names the repository it forks: the site's, or the sandbox the // override sends the run to (#251). Whatever it is given, it says. @@ -18,3 +20,10 @@ test('every other stage has a label, and an unknown one still says something', ( assert.strictEqual(prStageLabel('opening', 'WordPress/gutenberg'), 'Opening the pull request…'); assert.strictEqual(prStageLabel('', 'WordPress/gutenberg'), 'Working…'); }); + +test('the stage labels are translated when they are read, not when the module loads', (t) => { + addFilter('i18n.gettext', 'test/pseudo-locale', (text) => pseudoLocalize(text)); + t.after(() => removeFilter('i18n.gettext', 'test/pseudo-locale')); + assert.strictEqual(prStageLabel('forking', 'WordPress/gutenberg'), pseudoLocalize('Creating your fork of %s…').replace('%s', 'WordPress/gutenberg')); + assert.strictEqual(prStageLabel('opening', 'WordPress/gutenberg'), pseudoLocalize('Opening the pull request…')); +}); diff --git a/tests/unit/project-type.test.cjs b/tests/unit/project-type.test.cjs index fae76e73..f620c35b 100644 --- a/tests/unit/project-type.test.cjs +++ b/tests/unit/project-type.test.cjs @@ -92,7 +92,7 @@ test('every project type carries the full shape consumers depend on', () => { } // Every target opens pull requests (#251), so every target carries the // whole destination's copy. - for (const key of ['prBlockedNote', 'prCost', 'prAfter', 'signInCannot', 'prNotesHelp', 'prLoopBack']) { + for (const key of ['prBlockedNote', 'prCost', 'prAfter', 'signInCannot', 'prNotesHelp', 'prLoopBack', 'prNeedsWorkItem']) { assert.equal(typeof cfg.cards[key], 'string', `${id}: cards.${key}`); } assert.equal(typeof cfg.cards.prHow.summary, 'string', `${id}: cards.prHow.summary`);