static-analysis/frontend: Import existing project (#1340)

This commit is contained in:
Bastien Abadie 2018-08-06 15:34:51 +02:00 коммит произвёл GitHub
Коммит c55338acc6
16 изменённых файлов: 9233 добавлений и 0 удалений

48
frontend/.neutrinorc.js Normal file
Просмотреть файл

@ -0,0 +1,48 @@
fs = require('fs');
const envs = {
CONFIG: process.env.CONFIG || 'staging',
};
const PORT = process.env.PORT || 8010;
// HTTPS can be disabled by setting HTTPS_DISABLED environment variable to
// true. Otherwise it will enforced either using automatically generated
// certificates or pre-generated ones.
const HTTPS = process.env.HTTPS_DISABLED ? false :
(process.env.SSL_CERT && process.env.SSL_KEY && process.env.SSL_CACERT) ?
{
cert: fs.readFileSync(process.env.SSL_CERT),
key: fs.readFileSync(process.env.SSL_KEY),
ca: fs.readFileSync(process.env.SSL_CACERT)
}
: true;
// Set environment variables to their default values if not defined
Object.keys(envs).forEach(env => !(env in process.env) && (process.env[env] = envs[env]));
module.exports = {
use: [
'@neutrinojs/standardjs',
[
'@neutrinojs/vue',
{
html: {
title: 'Mozilla Static Analysis'
},
devServer: {
port: PORT,
https: HTTPS,
disableHostCheck: true,
historyApiFallback: {
rewrites: [
{ from: '__heartbeat__', to: 'views/ok.html' },
{ from: '__lbheartbeat__', to: 'views/ok.html' },
{ from: '__version__', to: 'views/version.json' },
],
},
}
}
],
'@neutrinojs/mocha',
['@neutrinojs/env', Object.keys(envs)],
]
};

0
frontend/README.md Normal file
Просмотреть файл

7
frontend/default.nix Normal file
Просмотреть файл

@ -0,0 +1,7 @@
{ releng_pkgs
}:
releng_pkgs.lib.mkYarnFrontend {
src = ./.;
src_path = ./.;
}

30
frontend/package.json Normal file
Просмотреть файл

@ -0,0 +1,30 @@
{
"name": "static-analysis-frontend",
"version": "1.0.0",
"repository": "https://github.com/mozilla/release-services",
"author": "Bastien Abadie <babadie@mozilla.com>",
"description": "Mozilla static analysis frontend",
"main": "index.js",
"dependencies": {
"axios": "^0.18.0",
"bulma": "^0.7.1",
"vue": "^2.5.16",
"vue-router": "^3.0.1",
"vuex": "^3.0.1"
},
"devDependencies": {
"@neutrinojs/mocha": "^8.3.0",
"@neutrinojs/standardjs": "^8.3.0",
"@neutrinojs/vue": "^8.3.0",
"neutrino": "^8.3.0"
},
"scripts": {
"build": "neutrino build",
"start": "neutrino start",
"lint": "neutrino lint",
"test": "neutrino test"
},
"keywords": [],
"author": "",
"license": "MPLv2"
}

112
frontend/src/App.vue Normal file
Просмотреть файл

@ -0,0 +1,112 @@
<script>
import Tasks from './Tasks.vue'
export default {
name: 'App',
components: {
Tasks
},
data () {
return {
channels: ['testing', 'staging', 'production']
}
},
methods: {
switch_channel (channel) {
this.$store.dispatch('switch_channel', channel)
}
},
computed: {
channel () {
return this.$store.state.channel
}
}
}
</script>
<template>
<div id="app">
<main>
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="container is-fluid">
<div class="navbar-brand">
<div class="navbar-item">Static analysis</div>
</div>
<div class="navbar-menu">
<div class="navbar-start">
<div class="navbar-item has-dropdown is-hoverable">
<span class="navbar-link">{{ channel }}</span>
<div class="navbar-dropdown is-boxed">
<a class="dropdown-item" v-for="c in channels" :class="{'is-active': c == channel}" v-on:click="switch_channel(c)">
{{ c }}
</a>
</div>
</div>
</div>
<div class="navbar-end">
<div class="navbar-item" v-if="$route.name != 'stats'">
<router-link to="/stats" class="button is-link">All checks</router-link>
</div>
<div class="navbar-item" v-if="$route.name != 'tasks'">
<router-link to="/" class="button is-link">All tasks</router-link>
</div>
</div>
</div>
</div>
</nav>
<div class="container is-fluid">
<router-view></router-view>
</div>
</main>
<footer>
Built by <a href="https://wiki.mozilla.org/Release_Management" target="_blank">Release Management team</a>
<span>&bull;</span>
<a href="https://github.com/La0/mozilla-static-analysis" target="_blank">Source Code</a>
<span>&bull;</span>
<a href="https://github.com/La0/mozilla-static-analysis/issues" target="_blank">Report an issue</a>
</footer>
</div>
</template>
<style scoped>
.navbar-brand .navbar-item {
font-size: 1.1em;
font-weight: bold;
color: #a3cc69 !important;
}
div.navbar-item.has-dropdown {
text-transform: capitalize;
}
/* Bottom footer support, it's not native in Bulma :( */
div#app {
display: flex;
min-height: 100vh;
flex-direction: column;
}
div#app main {
flex: 1;
}
div#app footer {
border-top: 1px solid #CCC;
padding: 2px;
font-size: 0.9em;
color: #444;
background: #EEE;
text-align: right;
}
div#app footer a:hover {
text-decoration: underline;
color: #3273dc;
}
div#app footer span {
color: #CCC;
}
</style>

13
frontend/src/Bool.vue Normal file
Просмотреть файл

@ -0,0 +1,13 @@
<script>
export default {
name: 'Bool',
props: ['value', 'name']
}
</script>
<template>
<span>
<span v-if="value" class="has-text-success">&#x2714; {{ name }}</span>
<span v-else class="has-text-danger">&#x2715; {{ name }}</span>
</span>
</template>

70
frontend/src/Check.vue Normal file
Просмотреть файл

@ -0,0 +1,70 @@
<script>
import mixins from './mixins.js'
export default {
mixins: [
mixins.stats
],
computed: {
check_name () {
return this.$route.params.check
},
check () {
if (!this.stats || !this.stats.loaded) {
return null
}
// Shallow clone to trigger Vue js reactivity on deeply nested objects
return Object.assign({}, this.$store.state.stats.checks[this.check_name])
}
}
}
</script>
<template>
<div>
<h1 class="title">Check {{ check_name }}</h1>
<h2 class="subtitle" v-if="stats && stats.ids">Loaded {{ stats.loaded }}/{{ stats.ids.length }} tasks</h2>
<div v-if="stats">
<progress class="progress is-info" :class="{'is-info': progress < 100, 'is-success': progress >= 100}" :value="progress" max="100">{{ progress }}%</progress>
<table class="table is-fullwidth" v-if="check">
<thead>
<tr>
<th>Task</th>
<th>Review</th>
<th>Path</th>
<th>Line</th>
<th>Message</th>
</tr>
</thead>
<tbody>
<tr v-for="issue in check.issues">
<td>
<a class="mono" :href="'https://tools.taskcluster.net/task-inspector/#' + issue.taskId" target="_blank">{{ issue.taskId }}</a>
</td>
<td>
<a :href="issue.revision.url" target="_blank" v-if="issue.revision.source == 'phabricator'">Phabricator {{ issue.revision.id }}</a>
<a :href="issue.revision.url" target="_blank" v-else-if="issue.revision.source == 'mozreview'">Mozreview {{ issue.revision.review_reques }}</a>
<span v-else>Unknown</span>
</td>
<td class="mono">{{ issue.path }}</td>
<td>{{ issue.line }}</td>
<td>
{{ issue.message }}
</td>
</tr>
</tbody>
</table>
<p v-else class="notification is-warning">No data available for this check</p>
</div>
<div class="notification is-info" v-else>Loading tasks...</div>
</div>
</template>
<style>
.mono{
font-family: monospace;
}
</style>

94
frontend/src/Stats.vue Normal file
Просмотреть файл

@ -0,0 +1,94 @@
<script>
import mixins from './mixins.js'
export default {
mixins: [
mixins.stats,
mixins.date
],
data () {
return {
sort: 'detected'
}
},
computed: {
checks () {
if (!this.stats || !this.stats.loaded) {
return null
}
let sortStr = (x, y) => x.toLowerCase().localeCompare(y.toLowerCase())
var sorts = {
'analyzer': (x, y) => sortStr(x.analyzer, y.analyzer) || sortStr(x.check, y.check),
'check': (x, y) => sortStr(x.check, y.check),
'detected': (x, y) => y.total - x.total,
'published': (x, y) => y.publishable - x.publishable
}
// Apply local sort to the checks from store
var checks = Object.values(this.stats.checks)
checks.sort(sorts[this.sort])
return checks
}
},
methods: {
select_sort (name) {
this.$set(this, 'sort', name)
}
}
}
</script>
<template>
<div>
<h1 class="title">Statistics</h1>
<h2 class="subtitle" v-if="stats && stats.ids">
<span>Loaded {{ stats.loaded }}/{{ stats.ids.length }} tasks with issues</span>
<span v-if="stats && stats.start_date" :title="stats.start_date">, since {{ stats.start_date|since }} ago</span>
</h2>
<div v-if="stats">
<progress class="progress is-info" :class="{'is-info': progress < 100, 'is-success': progress >= 100}" :value="progress" max="100">{{ progress }}%</progress>
<table class="table is-fullwidth" v-if="checks">
<thead>
<tr>
<th>
<span class="button" v-on:click="select_sort('analyzer')" :class="{'is-focused': sort == 'analyzer' }">Analyzer</span>
</th>
<th>
<span class="button" v-on:click="select_sort('check')" :class="{'is-focused': sort == 'check' }">Check</span>
</th>
<th>
<span class="button" v-on:click="select_sort('detected')" :class="{'is-focused': sort == 'detected' }">Detected</span>
</th>
<th>
<span class="button" v-on:click="select_sort('published')" :class="{'is-focused': sort == 'published' }">Published</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="check in checks" :class="{'publishable': check.publishable > 0}">
<td>{{ check.analyzer }}</td>
<td>
{{ check.check }}
<span class="has-text-grey" v-if="check.analyzer == 'mozlint.flake8'">{{ check.message }}</span>
</td>
<td>{{ check.total }}</td>
<td>
<router-link v-if="check.publishable > 0" :to="{ name: 'check', params: { check: check.key }}">{{ check.publishable }}</router-link>
<span class="has-text-grey" v-else>0</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="notification is-info" v-else>Loading tasks...</div>
</div>
</template>
<style scoped>
tr.publishable {
background: #e6ffcc;
}
</style>

155
frontend/src/Task.vue Normal file
Просмотреть файл

@ -0,0 +1,155 @@
<script>
import Bool from './Bool.vue'
export default {
name: 'Task',
data () {
return {
state: 'loading'
}
},
components: {
Bool
},
mounted () {
var report = this.$store.dispatch('load_report', this.$route.params.taskId)
report.then(
(response) => {
this.$set(this, 'state', 'loaded')
},
(error) => {
this.$set(this, 'state', error.response.status === 404 ? 'missing' : 'error')
}
)
},
computed: {
report () {
return this.$store.state.report
},
nb_publishable () {
if (!this.report || !this.report.issues) {
return 0
}
return this.report.issues.filter(i => i.publishable).length
}
},
filters: {
from_timestamp (value) {
return new Date(value * 1000).toUTCString()
}
}
}
</script>
<template>
<div>
<h1 class="title">Task <a :href="'https://tools.taskcluster.net/task-inspector/#' + $route.params.taskId" target="_blank">{{ $route.params.taskId }}</a></h1>
<div class="notification is-info" v-if="state == 'loading'">Loading report...</div>
<div class="notification is-warning" v-else-if="state == 'missing'">No report, so no issues !</div>
<div class="notification is-danger" v-else-if="state == 'error'">Failure</div>
<div v-else>
<nav class="level" v-if="report">
<div class="level-item has-text-centered">
<div>
<p class="heading">Publishable</p>
<p class="title">{{ nb_publishable }}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Issues</p>
<p class="title">{{ report.issues.length }}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Source</p>
<p class="title"><a :href="report.revision.url" target="_blank">{{ report.revision.source }}</a></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Reported</p>
<p class="title">{{ report.time|from_timestamp }}</p>
</div>
</div>
</nav>
<table class="table is-fullwidth" v-if="report && report.issues">
<thead>
<tr>
<td>Analyzer</td>
<td>Path</td>
<td>Lines</td>
<td>Publication</td>
<td>Check</td>
<td>Level</td>
<td>Message</td>
</tr>
</thead>
<tbody>
<tr v-for="issue in report.issues" :class="{'publishable': issue.publishable}">
<td>
<span v-if="issue.analyzer == 'mozlint'">{{ issue.linter }}<br />by Mozlint</span>
<span v-else>{{ issue.analyzer }}</span>
</td>
<td class="path">{{ issue.path }}</td>
<td>{{ issue.line }} <span v-if="issue.nb_lines > 1">&rarr; {{ issue.line - 1 + issue.nb_lines }}</span></td>
<td>
<Bool :value="issue.publishable" name="Publishable" />
<ul>
<li><Bool :value="issue.in_patch" name="In patch" /></li>
<li><Bool :value="issue.validates" name="Validated" /></li>
<li><Bool :value="issue.publishable" name="New issue" /></li>
</ul>
</td>
<td>
<span v-if="issue.analyzer == 'mozlint'">{{ issue.rule }}</span>
<span v-if="issue.analyzer == 'clang-tidy'">{{ issue.check }}</span>
</td>
<td>
<span v-if="issue.level == 'error' || issue.type == 'error'" class="tag is-danger">Error</span>
<span v-if="issue.level == 'warning' || issue.type == 'warning'" class="tag is-warning">Warning</span>
</td>
<td>
{{ issue.message }}
<pre v-if="issue.body">
{{ issue.body }}
</pre>
<div v-if="issue.analyzer == 'clang-format' && issue.mode == 'replace'">
<strong>Replace</strong>
<pre>{{ issue.old_lines }}</pre>
<strong>by these:</strong>
<pre>{{ issue.new_lines }}</pre>
</div>
<div v-if="issue.analyzer == 'clang-format' && issue.mode == 'insert'">
<strong>Insert these lines</strong>
<pre>{{ issue.new_lines }}</pre>
</div>
<div v-if="issue.analyzer == 'clang-format' && issue.mode == 'delete'">
<strong>Delete these lines</strong>
<pre>{{ issue.old_lines }}</pre>
</div>
</td>
</tr>
</tbody>
</table>
<p class="notification is-info" v-else>No issues !</p>
</div>
</div>
</template>
<style scoped>
tr.publishable {
background: #e6ffcc;
}
td.path {
color: #4d4d4d;
font-family: monospace;
}
</style>

90
frontend/src/Tasks.vue Normal file
Просмотреть файл

@ -0,0 +1,90 @@
<script>
import mixins from './mixins.js'
export default {
mounted () {
this.$store.dispatch('load_all_indexes')
},
mixins: [
mixins.date
],
computed: {
tasks () {
return this.$store.state.tasks
}
}
}
</script>
<template>
<table class="table is-fullwidth">
<thead>
<tr>
<td>#</td>
<td>Revision</td>
<td>State</td>
<td>Nb. Issues</td>
<td>Indexed</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
<tr v-for="task in tasks">
<td>
<a class="mono" :href="'https://tools.taskcluster.net/task-inspector/#' + task.taskId" target="_blank">{{ task.taskId }}</a>
</td>
<td v-if="task.data.source == 'mozreview'">
<span class="tag is-success">MozReview</span> #{{ task.data.review_request }}
<br />
<small class="mono has-dark-text">{{ task.data.rev}}</small>
</td>
<td v-else-if="task.data.source == 'phabricator'">
<span class="tag is-dark">Phabricator</span> #{{ task.data.id }}
<br />
<small class="mono has-dark-text">{{ task.data.diff_phid}}</small>
</td>
<td v-else>
<p class="notification is-danger">Unknown data source: {{ task.data.source }}</p>
</td>
<td>
<span class="tag is-light" v-if="task.data.state == 'started'">Started</span>
<span class="tag is-info" v-else-if="task.data.state == 'cloned'">Cloned</span>
<span class="tag is-info" v-else-if="task.data.state == 'analyzing'">Analyzing</span>
<span class="tag is-primary" v-else-if="task.data.state == 'analyzed'">Analyzed</span>
<span class="tag is-danger" v-else-if="task.data.state == 'error'">Error</span>
<span class="tag is-success" v-else-if="task.data.state == 'done'">Done</span>
<span class="tag is-black" v-else>Unknown</span>
</td>
<td :class="{'has-text-success': task.data.issues_publishable > 0}">
<span v-if="task.data.issues_publishable > 0">{{ task.data.issues_publishable }}</span>
<span v-else-if="task.data.issues_publishable == 0">{{ task.data.issues_publishable }}</span>
<span v-else>-</span>
/ {{ task.data.issues }}
</td>
<td>
<span :title="task.data.indexed">{{ task.data.indexed|since }} ago</span>
</td>
<td>
<a class="button is-link" :href="task.data.url" target="_blank">Review</a>
<router-link :to="{ name: 'task', params: { taskId : task.taskId }}" class="button is-primary">Details</router-link>
</td>
</tr>
</tbody>
</table>
</template>
<style>
a.mono{
font-family: monospace;
}
</style>

15
frontend/src/index.js Normal file
Просмотреть файл

@ -0,0 +1,15 @@
import Vue from 'vue'
import 'bulma/css/bulma.css'
import App from './App.vue'
import store from './store.js'
import router from './routes.js'
export default new Vue({
store,
router,
el: '#root',
render: (h) => h(App),
beforeCreate () {
this.$store.commit('load_preferences')
}
})

44
frontend/src/mixins.js Normal file
Просмотреть файл

@ -0,0 +1,44 @@
export default {
stats: {
mounted () {
this.$store.dispatch('calc_stats')
},
computed: {
stats () {
return this.$store.state.stats
},
progress () {
if (!this.stats || !this.stats.ids) {
return null
}
return 100 * this.stats.loaded / this.stats.ids.length
}
}
},
date: {
filters: {
// Display time since elapsed in a human format
since (datetime) {
var dspStep = (t, name) => (Math.round(t) + ' ' + name + (Math.round(t) > 1 ? 's' : ''))
let diff = (new Date() - new Date(datetime)) / 1000
let steps = [
[60, 'second'],
[60, 'minute'],
[24, 'hour'],
[30, 'day']
]
var prev = ''
for (let [t, name] of steps) {
if (diff > t) {
prev = dspStep(diff % t, name)
diff = diff / t
} else {
return dspStep(diff, name) + ' ' + prev
}
}
return 'Too long ago'
}
}
}
}

33
frontend/src/routes.js Normal file
Просмотреть файл

@ -0,0 +1,33 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Tasks from './Tasks.vue'
import Task from './Task.vue'
import Stats from './Stats.vue'
import Check from './Check.vue'
Vue.use(VueRouter)
export default new VueRouter({
routes: [
{
path: '/',
name: 'tasks',
component: Tasks
},
{
path: '/task/:taskId',
name: 'task',
component: Task
},
{
path: '/stats',
name: 'stats',
component: Stats
},
{
path: '/check/:check',
name: 'check',
component: Check
}
]
})

179
frontend/src/store.js Normal file
Просмотреть файл

@ -0,0 +1,179 @@
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
import router from './routes'
Vue.use(Vuex)
const PREFERENCES_KEY = 'mozilla-sa-dashboard'
const TASKCLUSTER_INDEX = 'https://index.taskcluster.net/v1'
const TASKCLUSTER_QUEUE = 'https://queue.taskcluster.net/v1'
const TASKS_SLICE = 10
export default new Vuex.Store({
state: {
channel: 'production',
tasks: [],
report: null,
stats: null
},
mutations: {
load_preferences (state) {
// Load prefs from local storage
let rawPrefs = localStorage.getItem(PREFERENCES_KEY)
if (rawPrefs) {
let prefs = JSON.parse(rawPrefs)
if (prefs.channel) {
state.channel = prefs.channel
}
}
},
save_preferences (state) {
// Save channel to preferences
localStorage.setItem(PREFERENCES_KEY, JSON.stringify({
channel: state.channel
}))
},
use_channel (state, channel) {
state.channel = channel
state.stats = null
state.report = null
this.commit('save_preferences')
},
reset_tasks (state) {
state.tasks = []
},
reset_stats (state) {
// List all active tasks Ids
var ids =
state.tasks
.filter((task) => task.data.state === 'done' && task.data.issues > 0)
.map(t => t.taskId)
state.stats = {
loaded: 0,
ids: ids,
checks: {},
start_date: new Date()
}
},
use_tasks (state, tasks) {
// Filter tasks without extra data
state.tasks = state.tasks.concat(
tasks.filter(task => task.data.indexed !== undefined)
)
// Sort by indexation date
state.tasks.sort((x, y) => {
return new Date(y.data.indexed) - new Date(x.data.indexed)
})
},
use_report (state, report) {
state.report = report
if (report !== null && state.stats !== null) {
// Calc stats for this report
// clang-format does not provide any check information
var checks = report.issues.filter(i => i.analyzer !== 'clang-format')
state.stats.checks = checks.reduce((stats, issue) => {
var analyzer = issue.analyzer + (issue.analyzer === 'mozlint' ? '.' + issue.linter : '')
var check = issue.analyzer === 'clang-tidy' ? issue.check : issue.rule
var key = analyzer + '.' + check
if (stats[key] === undefined) {
stats[key] = {
analyzer: analyzer,
key: key,
message: issue.message,
check: check,
publishable: 0,
issues: [],
total: 0
}
}
stats[key].publishable += issue.publishable ? 1 : 0
stats[key].total++
// Save publishable issues for Check component
// and link report data to the issue
if (issue.publishable) {
let extras = {
revision: report.revision,
taskId: report.taskId
}
stats[key].issues.push(Object.assign(extras, issue))
}
return stats
}, state.stats.checks)
// Save start date
state.stats.start_date = new Date(Math.min(new Date(report.time * 1000.0), state.stats.start_date))
// Mark new report loaded
state.stats.loaded += 1
}
}
},
actions: {
// Switch data channel to use
switch_channel (state, channel) {
state.commit('use_channel', channel)
state.dispatch('load_all_indexes')
router.push({ name: 'tasks' })
},
// Load all indexes available
load_all_indexes (state) {
state.commit('reset_tasks')
return Promise.all([
state.dispatch('load_index', 'mozreview'),
state.dispatch('load_index', 'phabricator')
])
},
// Load indexed tasks summary from Taskcluster
load_index (state, namespace) {
let url = TASKCLUSTER_INDEX + '/tasks/project.releng.services.project.' + this.state.channel + '.shipit_static_analysis.' + namespace
return axios.get(url).then(resp => {
state.commit('use_tasks', resp.data.tasks)
})
},
// Load the report for a given task
load_report (state, taskId) {
let url = TASKCLUSTER_QUEUE + '/task/' + taskId + '/artifacts/public/results/report.json'
state.commit('use_report', null)
return axios.get(url).then(resp => {
state.commit('use_report', Object.assign({ taskId }, resp.data))
})
},
// Load multiple reports for stats crunching
calc_stats (state, tasksId) {
// Avoid multiple loads
if (state.state.stats !== null) {
return
}
// Load all indexes to get task ids
var indexes = state.dispatch('load_all_indexes')
indexes.then(() => {
console.log('Start analysis')
state.commit('reset_stats')
// Start processing by batches
state.dispatch('load_report_batch', 0)
})
},
load_report_batch (state, step) {
if (step * TASKS_SLICE > state.state.stats.ids.length) {
return
}
// Slice full loading in smaller batches to avoid using too many ressources
var slice = state.state.stats.ids.slice(step * TASKS_SLICE, (step + 1) * TASKS_SLICE)
var batch = Promise.all(slice.map(taskId => state.dispatch('load_report', taskId)))
batch.then(resp => console.info('Loaded batch', step))
batch.then(resp => state.dispatch('load_report_batch', step + 1))
}
}
})

Просмотреть файл

@ -0,0 +1,7 @@
import assert from 'assert';
describe('simple', () => {
it('should be sane', () => {
assert.equal(true, !false);
});
});

8336
frontend/yarn.lock Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу