Skip to content

Commit a1d93a7

Browse files
authoredFeb 1, 2021
Lint translation files (github#17561)
### Why: A lot of content gets mistranslated, some common patterns are: frontmatter date and enums getting translated, liquid tags get translated or go missing during the translation process. These translation errors are tough to catch, especially when they often come in huge PRs. ### What's being changed: - Frontmatter is also getting linted against schema as part of `lint-files` - When an environment variable `TEST_TRANSLATION` is passed, - `lint-files` will run its tests against all files that have been newly translated (by git-diffing between `translations` branch and `main` branch), and - results are outputted using a custom jest reporter. The output is formatted in a way that makes it easy to exclude the problematic translated files from being merged, and to share the errors with [localization support](https://github.com/github/localization-support/issues/489). - Run the implemented translation test as part of the existing `Node.js Tests - Translations` workflow
1 parent 37fd4bd commit a1d93a7

File tree

5 files changed

+170
-54
lines changed

5 files changed

+170
-54
lines changed
 

‎.github/workflows/test-translations.yml

+6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ jobs:
1717
with:
1818
ref: translations # check out the 'translations' branch
1919

20+
- name: Check out tip of main
21+
run: git fetch --depth=1 origin main
22+
2023
- name: Setup node
2124
uses: actions/setup-node@c46424eee26de4078d34105d3de3cc4992202b1e
2225
with:
@@ -41,6 +44,9 @@ jobs:
4144
- name: Run linter
4245
run: npx eslint .
4346

47+
- name: Lint translated content
48+
run: npm run lint-translation
49+
4450
- name: Check dependencies
4551
run: npm run check-deps
4652

‎jest.config.js

+11-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
const isBrowser = process.env.BROWSER
44
const isActions = Boolean(process.env.GITHUB_ACTIONS)
5+
const testTranslation = Boolean(process.env.TEST_TRANSLATION)
6+
7+
let reporters = ['default']
8+
9+
if (testTranslation) {
10+
// only use custom reporter if we are linting translations
11+
reporters = ['<rootDir>/tests/helpers/lint-translation-reporter.js']
12+
} else if (isActions) {
13+
reporters.push('jest-github-actions-reporter')
14+
}
515

616
module.exports = {
717
coverageThreshold: {
@@ -15,9 +25,7 @@ module.exports = {
1525
preset: isBrowser
1626
? 'jest-puppeteer'
1727
: undefined,
18-
reporters: isActions
19-
? ['default', 'jest-github-actions-reporter']
20-
: ['default'],
28+
reporters,
2129
modulePathIgnorePatterns: [
2230
'assets/'
2331
],

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@
170170
"build": "cross-env NODE_ENV=production npx webpack --mode production",
171171
"start-all-languages": "cross-env NODE_ENV=development nodemon server.js",
172172
"lint": "eslint --fix . && prettier -w \"**/*.{yml,yaml}\"",
173+
"lint-translation": "TEST_TRANSLATION=true jest content/lint-files",
173174
"test": "jest && eslint . && prettier -c \"**/*.{yml,yaml}\" && npm run check-deps",
174175
"prebrowser-test": "npm run build",
175176
"browser-test": "start-server-and-test browser-test-server 4001 browser-test-tests",

‎tests/content/lint-files.js

+109-51
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const path = require('path')
22
const slash = require('slash')
33
const fs = require('fs')
44
const walk = require('walk-sync')
5-
const { zip } = require('lodash')
5+
const { zip, groupBy } = require('lodash')
66
const yaml = require('js-yaml')
77
const revalidator = require('revalidator')
88
const generateMarkdownAST = require('mdast-util-from-markdown')
@@ -12,12 +12,14 @@ const languages = require('../../lib/languages')
1212
const { tags } = require('../../lib/liquid-tags/extended-markdown')
1313
const ghesReleaseNotesSchema = require('../../lib/release-notes-schema')
1414
const renderContent = require('../../lib/render-content')
15+
const { execSync } = require('child_process')
1516

1617
const rootDir = path.join(__dirname, '../..')
1718
const contentDir = path.join(rootDir, 'content')
1819
const reusablesDir = path.join(rootDir, 'data/reusables')
1920
const variablesDir = path.join(rootDir, 'data/variables')
2021
const glossariesDir = path.join(rootDir, 'data/glossaries')
22+
const ghesReleaseNotesDir = path.join(rootDir, 'data/release-notes')
2123

2224
const languageCodes = Object.keys(languages)
2325

@@ -149,13 +151,26 @@ const oldVariableErrorText = 'Found article uses old {{ site.data... }} syntax.
149151
const oldOcticonErrorText = 'Found octicon variables with the old {{ octicon-name }} syntax. Use {% octicon "name" %} instead!'
150152
const oldExtendedMarkdownErrorText = 'Found extended markdown tags with the old {{#note}} syntax. Use {% note %}/{% endnote %} instead!'
151153

152-
describe('lint-files', () => {
153-
const mdWalkOptions = {
154-
globs: ['**/*.md'],
155-
ignore: ['**/README.md'],
156-
directories: false,
157-
includeBasePath: true
158-
}
154+
const mdWalkOptions = {
155+
globs: ['**/*.md'],
156+
ignore: ['**/README.md'],
157+
directories: false,
158+
includeBasePath: true
159+
}
160+
161+
// Also test the "data/variables/" YAML files
162+
163+
const yamlWalkOptions = {
164+
globs: ['**/*.yml'],
165+
directories: false,
166+
includeBasePath: true
167+
}
168+
169+
// different lint rules apply to different content types
170+
let mdToLint, ymlToLint, releaseNotesToLint
171+
172+
if (!process.env.TEST_TRANSLATION) {
173+
// compile lists of all the files we want to lint
159174

160175
const contentMarkdownAbsPaths = walk(contentDir, mdWalkOptions).sort()
161176
const contentMarkdownRelPaths = contentMarkdownAbsPaths.map(p => slash(path.relative(rootDir, p)))
@@ -165,16 +180,81 @@ describe('lint-files', () => {
165180
const reusableMarkdownRelPaths = reusableMarkdownAbsPaths.map(p => slash(path.relative(rootDir, p)))
166181
const reusableMarkdownTuples = zip(reusableMarkdownRelPaths, reusableMarkdownAbsPaths)
167182

168-
describe.each([...contentMarkdownTuples, ...reusableMarkdownTuples])(
169-
'in "%s"',
183+
mdToLint = [...contentMarkdownTuples, ...reusableMarkdownTuples]
184+
185+
// data/variables
186+
const variableYamlAbsPaths = walk(variablesDir, yamlWalkOptions).sort()
187+
const variableYamlRelPaths = variableYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
188+
const variableYamlTuples = zip(variableYamlRelPaths, variableYamlAbsPaths)
189+
190+
// data/glossaries
191+
const glossariesYamlAbsPaths = walk(glossariesDir, yamlWalkOptions).sort()
192+
const glossariesYamlRelPaths = glossariesYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
193+
const glossariesYamlTuples = zip(glossariesYamlRelPaths, glossariesYamlAbsPaths)
194+
195+
ymlToLint = [...variableYamlTuples, ...glossariesYamlTuples]
196+
197+
// GHES release notes
198+
const ghesReleaseNotesYamlAbsPaths = walk(ghesReleaseNotesDir, yamlWalkOptions).sort()
199+
const ghesReleaseNotesYamlRelPaths = ghesReleaseNotesYamlAbsPaths.map(p => path.relative(rootDir, p))
200+
releaseNotesToLint = zip(ghesReleaseNotesYamlRelPaths, ghesReleaseNotesYamlAbsPaths)
201+
} else {
202+
console.log('testing translations.')
203+
204+
// get all translated markdown or yaml files by comparing files changed to main branch
205+
const changedFilesRelPaths = execSync('git diff --name-only origin/main | egrep "^translations/.*/.+.(yml|md)$"').toString().split('\n')
206+
console.log(`Found ${changedFilesRelPaths.length} translated files.`)
207+
208+
const { mdRelPaths, ymlRelPaths, releaseNotesRelPaths } = groupBy(changedFilesRelPaths, (path) => {
209+
// separate the changed files to different groups
210+
if (path.endsWith('README.md')) {
211+
return 'throwAway'
212+
} else if (path.endsWith('.md')) {
213+
return 'mdRelPaths'
214+
} else if (path.match(/\/data\/(variables|glossaries)\//i)) {
215+
return 'ymlRelPaths'
216+
} else if (path.match(/\/data\/release-notes\//i)) {
217+
return 'releaseNotesRelPaths'
218+
} else {
219+
// we aren't linting the rest
220+
return 'throwAway'
221+
}
222+
})
223+
224+
const [mdTuples, ymlTuples, releaseNotesTuples] = [mdRelPaths, ymlRelPaths, releaseNotesRelPaths].map(relPaths => {
225+
const absPaths = relPaths.map(p => path.join(rootDir, p))
226+
return zip(relPaths, absPaths)
227+
})
228+
229+
mdToLint = mdTuples
230+
ymlToLint = ymlTuples
231+
releaseNotesToLint = releaseNotesTuples
232+
}
233+
234+
function formatLinkError (message, links) {
235+
return `${message}\n - ${links.join('\n - ')}`
236+
}
237+
238+
// Returns `content` if its a string, or `content.description` if it can.
239+
// Used for getting the nested `description` key in glossary files.
240+
function getContent (content) {
241+
if (typeof content === 'string') return content
242+
if (typeof content.description === 'string') return content.description
243+
return null
244+
}
245+
246+
describe('lint markdown content', () => {
247+
describe.each(mdToLint)(
248+
'%s',
170249
(markdownRelPath, markdownAbsPath) => {
171-
let content, ast, links, isHidden, isEarlyAccess, isSitePolicy
250+
let content, ast, links, isHidden, isEarlyAccess, isSitePolicy, frontmatterErrors
172251

173252
beforeAll(async () => {
174253
const fileContents = await fs.promises.readFile(markdownAbsPath, 'utf8')
175-
const { data, content: bodyContent } = frontmatter(fileContents)
254+
const { data, content: bodyContent, errors } = frontmatter(fileContents)
176255

177256
content = bodyContent
257+
frontmatterErrors = errors
178258
ast = generateMarkdownAST(content)
179259
isHidden = data.hidden === true
180260
isEarlyAccess = markdownRelPath.split('/').includes('early-access')
@@ -307,34 +387,20 @@ describe('lint-files', () => {
307387
.resolves
308388
.toBeTruthy()
309389
})
390+
391+
if (!markdownRelPath.includes('data/reusables')) {
392+
test('contains valid frontmatter', () => {
393+
const errorMessage = frontmatterErrors.map(error => `- [${error.property}]: ${error.actual}, ${error.message}`).join('\n')
394+
expect(frontmatterErrors.length, errorMessage).toBe(0)
395+
})
396+
}
310397
}
311398
)
399+
})
312400

313-
// Also test the "data/variables/" YAML files
314-
const yamlWalkOptions = {
315-
globs: ['**/*.yml'],
316-
directories: false,
317-
includeBasePath: true
318-
}
319-
320-
const variableYamlAbsPaths = walk(variablesDir, yamlWalkOptions).sort()
321-
const variableYamlRelPaths = variableYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
322-
const variableYamlTuples = zip(variableYamlRelPaths, variableYamlAbsPaths)
323-
324-
const glossariesYamlAbsPaths = walk(glossariesDir, yamlWalkOptions).sort()
325-
const glossariesYamlRelPaths = glossariesYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
326-
const glossariesYamlTuples = zip(glossariesYamlRelPaths, glossariesYamlAbsPaths)
327-
328-
// Returns `content` if its a string, or `content.description` if it can.
329-
// Used for getting the nested `description` key in glossary files.
330-
function getContent (content) {
331-
if (typeof content === 'string') return content
332-
if (typeof content.description === 'string') return content.description
333-
return null
334-
}
335-
336-
describe.each([...variableYamlTuples, ...glossariesYamlTuples])(
337-
'in "%s"',
401+
describe('lint yaml content', () => {
402+
describe.each(ymlToLint)(
403+
'%s',
338404
(yamlRelPath, yamlAbsPath) => {
339405
let dictionary, isEarlyAccess
340406

@@ -518,16 +584,12 @@ describe('lint-files', () => {
518584
})
519585
}
520586
)
587+
})
521588

522-
// GHES release notes
523-
const ghesReleaseNotesDir = path.join(__dirname, '../../data/release-notes')
524-
const ghesReleaseNotesYamlAbsPaths = walk(ghesReleaseNotesDir, yamlWalkOptions).sort()
525-
const ghesReleaseNotesYamlRelPaths = ghesReleaseNotesYamlAbsPaths.map(p => path.relative(rootDir, p))
526-
const ghesReleaseNotesYamlTuples = zip(ghesReleaseNotesYamlRelPaths, ghesReleaseNotesYamlAbsPaths)
527-
528-
if (ghesReleaseNotesYamlTuples.length > 0) {
529-
describe.each(ghesReleaseNotesYamlTuples)(
530-
'in "%s"',
589+
describe('lint release notes', () => {
590+
if (releaseNotesToLint.length > 0) {
591+
describe.each(releaseNotesToLint)(
592+
'%s',
531593
(yamlRelPath, yamlAbsPath) => {
532594
let dictionary
533595

@@ -538,14 +600,10 @@ describe('lint-files', () => {
538600

539601
it('matches the schema', () => {
540602
const { errors } = revalidator.validate(dictionary, ghesReleaseNotesSchema)
541-
const errorMessage = errors.map(error => `- [${error.property}]: ${error.attribute}, ${error.message}`).join('\n')
603+
const errorMessage = errors.map(error => `- [${error.property}]: ${error.actual}, ${error.message}`).join('\n')
542604
expect(errors.length, errorMessage).toBe(0)
543605
})
544606
}
545607
)
546608
}
547609
})
548-
549-
function formatLinkError (message, links) {
550-
return `${message}\n - ${links.join('\n - ')}`
551-
}
+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const chalk = require('chalk')
2+
const { groupBy } = require('lodash')
3+
4+
// we don't want to print all the stack traces
5+
const stackTrackRegExp = /^\s+at\s.+/i
6+
7+
class TranslationReporter {
8+
constructor (globalConfig, options) {
9+
this._globalConfig = globalConfig
10+
this._options = options
11+
}
12+
13+
onRunComplete (contexts, results) {
14+
const failures = results.testResults.reduce((fails, { testResults: assertionResults }) => {
15+
const formattedFails = assertionResults
16+
.filter(result => result.status === 'failed')
17+
.map(({ ancestorTitles, failureMessages, title }) => {
18+
return {
19+
fileName: ancestorTitles[1],
20+
failedTests: title,
21+
failureMessage: failureMessages.map((message) => message.split('\n').filter(line => !stackTrackRegExp.test(line)).join('\n'))
22+
}
23+
})
24+
return [...fails, ...formattedFails]
25+
}, [])
26+
27+
const failuresByFile = groupBy(failures, 'fileName')
28+
29+
for (const fileName in failuresByFile) {
30+
console.group(chalk.red.bold(`\n${fileName}`))
31+
failuresByFile[fileName].forEach(({ failureMessage }, index) => {
32+
console.log(chalk.bold(`\n(${index + 1})`))
33+
failureMessage.forEach(msg => console.log(msg))
34+
})
35+
console.groupEnd()
36+
}
37+
38+
console.log(chalk.bold('\nthese files should not be included: '))
39+
console.dir(Object.keys(failuresByFile), { maxArrayLength: null })
40+
}
41+
}
42+
43+
module.exports = TranslationReporter

0 commit comments

Comments
 (0)
Please sign in to comment.