Skip to content

Commit af4efa5

Browse files
authored
repo sync
2 parents f43e43b + a1d93a7 commit af4efa5

5 files changed

Lines changed: 170 additions & 54 deletions

File tree

.github/workflows/test-translations.yml

Lines changed: 6 additions & 0 deletions
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

Lines changed: 11 additions & 3 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 109 additions & 51 deletions
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-
}
Lines changed: 43 additions & 0 deletions
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)