fix(frontend): GIFバナーの復活など (#10247)

* Restore GIF banner

* Add ALT banner, detect APNG too

* Add vitest

* Add CI for vitest

* Upload coverage?

* frontend
This commit is contained in:
Kagami Sascha Rosylight 2023-03-09 04:48:39 +01:00 committed by GitHub
parent 6607b39235
commit 4835f0fb43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 835 additions and 111 deletions

View file

@ -8,7 +8,44 @@ on:
pull_request: pull_request:
jobs: jobs:
cypress: vitest:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- uses: actions/checkout@v3.3.0
with:
submodules: true
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 7
run_install: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.6.0
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- run: corepack enable
- run: pnpm i --frozen-lockfile
- name: Check pnpm-lock.yaml
run: git diff --exit-code pnpm-lock.yaml
- name: Copy Configure
run: cp .github/misskey/test.yml .config
- name: Build
run: pnpm build
- name: Test
run: pnpm --filter frontend test-and-coverage
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./packages/frontend/coverage/coverage-final.json
e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:

View file

@ -31,8 +31,8 @@
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
"jest": "cd packages/backend && pnpm jest", "jest": "cd packages/backend && pnpm jest",
"jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage",
"test": "pnpm jest", "test": "pnpm -r test",
"test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage": "pnpm -r test-and-coverage",
"format": "pnpm exec gulp format", "format": "pnpm exec gulp format",
"clean": "node ./scripts/clean.js", "clean": "node ./scripts/clean.js",
"clean-all": "node ./scripts/clean-all.js", "clean-all": "node ./scripts/clean-all.js",

View file

@ -4,6 +4,8 @@
"scripts": { "scripts": {
"watch": "vite", "watch": "vite",
"build": "vite build", "build": "vite build",
"test": "vitest --run",
"test-and-coverage": "vitest --run --coverage",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
@ -70,6 +72,7 @@
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/vue": "^6.6.1",
"@types/escape-regexp": "0.0.1", "@types/escape-regexp": "0.0.1",
"@types/gulp": "4.0.10", "@types/gulp": "4.0.10",
"@types/gulp-rename": "2.0.1", "@types/gulp-rename": "2.0.1",
@ -85,13 +88,16 @@
"@types/ws": "8.5.4", "@types/ws": "8.5.4",
"@typescript-eslint/eslint-plugin": "5.53.0", "@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.53.0", "@typescript-eslint/parser": "5.53.0",
"@vitest/coverage-c8": "^0.29.2",
"@vue/runtime-core": "3.2.47", "@vue/runtime-core": "3.2.47",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "12.7.0", "cypress": "12.7.0",
"eslint": "8.35.0", "eslint": "8.35.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.9.0", "eslint-plugin-vue": "9.9.0",
"happy-dom": "8.9.0",
"start-server-and-test": "1.15.4", "start-server-and-test": "1.15.4",
"vitest": "^0.29.2",
"vue-eslint-parser": "9.1.0", "vue-eslint-parser": "9.1.0",
"vue-tsc": "1.2.0" "vue-tsc": "1.2.0"
} }

View file

@ -3,21 +3,24 @@
<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
<div :class="$style.hiddenText"> <div :class="$style.hiddenText">
<div :class="$style.hiddenTextWrapper"> <div :class="$style.hiddenTextWrapper">
<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b> <b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b>
<span style="display: block;">{{ $ts.clickToShow }}</span> <span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div> </div>
</div> </div>
</div> </div>
<div v-else :class="$style.visible" :style="defaultStore.state.darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'"> <div v-else :class="$style.visible" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'">
<a <a
:class="$style.imageContainer" :class="$style.imageContainer"
:href="image.url" :href="image.url"
:title="image.name" :title="image.name"
> >
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/> <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/>
<div v-if="image.type === 'image/gif'" :class="$style.gif">GIF</div>
</a> </a>
<button v-tooltip="$ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button> <div :class="$style.indicators">
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
<div v-if="image.comment" :class="$style.indicator">ALT</div>
</div>
<button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button>
</div> </div>
</template> </template>
@ -27,6 +30,7 @@ import * as misskey from 'misskey-js';
import { getStaticImageUrl } from '@/scripts/media-proxy'; import { getStaticImageUrl } from '@/scripts/media-proxy';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
image: misskey.entities.DriveFile; image: misskey.entities.DriveFile;
@ -34,6 +38,7 @@ const props = defineProps<{
}>(); }>();
let hide = $ref(true); let hide = $ref(true);
let darkMode = $ref(defaultStore.state.darkMode);
const url = (props.raw || defaultStore.state.loadRawImages) const url = (props.raw || defaultStore.state.loadRawImages)
? props.image.url ? props.image.url
@ -108,18 +113,25 @@ watch(() => props.image, () => {
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.gif { .indicators {
background-color: var(--fg); display: inline-flex;
position: absolute;
top: 12px;
left: 12px;
text-align: center;
pointer-events: none;
opacity: .5;
font-size: 14px;
gap: 6px;
}
.indicator {
/* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */
background-color: black;
border-radius: 6px; border-radius: 6px;
color: var(--accentLighten); color: var(--accentLighten);
display: inline-block; display: inline-block;
font-size: 14px;
font-weight: bold; font-weight: bold;
left: 12px;
opacity: .5;
padding: 0 6px; padding: 0 6px;
text-align: center;
top: 12px;
pointer-events: none;
} }
</style> </style>

View file

@ -14,17 +14,23 @@ import adaptiveBg from './adaptive-bg';
import container from './container'; import container from './container';
export default function(app: App) { export default function(app: App) {
app.directive('userPreview', userPreview); for (const [key, value] of Object.entries(directives)) {
app.directive('user-preview', userPreview); app.directive(key, value);
app.directive('get-size', getSize); }
app.directive('ripple', ripple);
app.directive('tooltip', tooltip);
app.directive('hotkey', hotkey);
app.directive('appear', appear);
app.directive('anim', anim);
app.directive('click-anime', clickAnime);
app.directive('panel', panel);
app.directive('adaptive-border', adaptiveBorder);
app.directive('adaptive-bg', adaptiveBg);
app.directive('container', container);
} }
export const directives = {
'userPreview': userPreview,
'user-preview': userPreview,
'get-size': getSize,
'ripple': ripple,
'tooltip': tooltip,
'hotkey': hotkey,
'appear': appear,
'anim': anim,
'click-anime': clickAnime,
'panel': panel,
'adaptive-border': adaptiveBorder,
'adaptive-bg': adaptiveBg,
'container': container,
};

View file

@ -0,0 +1,18 @@
import { vi } from 'vitest';
// Set i18n
import locales from '../../../locales';
import { updateI18n } from '@/i18n';
updateI18n(locales['en-US']);
// XXX: misskey-js panics if WebSocket is not defined
vi.stubGlobal('WebSocket', class WebSocket extends EventTarget { static CLOSING = 2; });
// XXX: defaultStore somehow becomes undefined in vitest?
vi.mock('@/store.js', () => {
return {
defaultStore: {
state: {},
},
};
});

View file

@ -0,0 +1,81 @@
import { describe, test, assert, afterEach } from 'vitest';
import { render, cleanup, type RenderResult } from '@testing-library/vue';
import './init';
import type { DriveFile } from 'misskey-js/built/entities';
import { directives } from '@/directives';
import MkMediaImage from '@/components/MkMediaImage.vue';
describe('MkMediaImage', () => {
const renderMediaImage = (image: Partial<DriveFile>): RenderResult => {
return render(MkMediaImage, {
props: { image },
global: { directives },
});
};
afterEach(() => {
cleanup();
});
test('Attaching JPG should show no indicator', async () => {
const mkMediaImage = renderMediaImage({
type: 'image/jpeg',
});
const [gif, alt] = await Promise.all([
mkMediaImage.queryByText('GIF'),
mkMediaImage.queryByText('ALT'),
]);
assert.ok(!gif);
assert.ok(!alt);
});
test('Attaching GIF should show a GIF indicator', async () => {
const mkMediaImage = renderMediaImage({
type: 'image/gif',
});
const [gif, alt] = await Promise.all([
mkMediaImage.queryByText('GIF'),
mkMediaImage.queryByText('ALT'),
]);
assert.ok(gif);
assert.ok(!alt);
});
test('Attaching APNG should show a GIF indicator', async () => {
const mkMediaImage = renderMediaImage({
type: 'image/apng',
});
const [gif, alt] = await Promise.all([
mkMediaImage.queryByText('GIF'),
mkMediaImage.queryByText('ALT'),
]);
assert.ok(gif);
assert.ok(!alt);
});
test('Attaching image with an alt message should show an ALT indicator', async () => {
const mkMediaImage = renderMediaImage({
type: 'image/png',
comment: 'Misskeyのロゴです',
});
const [gif, alt] = await Promise.all([
mkMediaImage.queryByText('GIF'),
mkMediaImage.queryByText('ALT'),
]);
assert.ok(!gif);
assert.ok(alt);
});
test('Attaching GIF image with an alt message should show a GIF and an ALT indicator', async () => {
const mkMediaImage = renderMediaImage({
type: 'image/gif',
comment: 'Misskeyのロゴです',
});
const [gif, alt] = await Promise.all([
mkMediaImage.queryByText('GIF'),
mkMediaImage.queryByText('ALT'),
]);
assert.ok(gif);
assert.ok(alt);
});
});

View file

@ -0,0 +1,43 @@
{
"compilerOptions": {
"allowJs": true,
"noEmitOnError": false,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"declaration": false,
"sourceMap": true,
"target": "es2021",
"module": "es2020",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"removeComments": false,
"noLib": false,
"strict": true,
"strictNullChecks": true,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": "./",
"paths": {
"@/*": ["../src/*"]
},
"typeRoots": [
"../node_modules/@types",
],
"lib": [
"esnext",
"dom"
],
"types": ["node"]
},
"compileOnSave": false,
"include": [
"./**/*.ts",
"../src/**/*.vue",
]
}

View file

@ -1,6 +1,7 @@
import path from 'path'; import path from 'path';
import pluginVue from '@vitejs/plugin-vue'; import pluginVue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { configDefaults as vitestConfigDefaults } from 'vitest/config';
import locales from '../../locales'; import locales from '../../locales';
import meta from '../../package.json'; import meta from '../../package.json';
@ -16,10 +17,10 @@ const hash = (str: string, seed = 0): number => {
h1 = Math.imul(h1 ^ ch, 2654435761); h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677); h2 = Math.imul(h2 ^ ch, 1597334677);
} }
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0); return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}; };
@ -28,12 +29,12 @@ function toBase62(n: number): string {
if (n === 0) { if (n === 0) {
return '0'; return '0';
} }
let result = ''; let result = '';
while (n > 0) { while (n > 0) {
result = BASE62_DIGITS[n % BASE62_DIGITS.length] + result; result = BASE62_DIGITS[n % BASE62_DIGITS.length] + result;
n = Math.floor(n / BASE62_DIGITS.length); n = Math.floor(n / BASE62_DIGITS.length);
} }
return result; return result;
} }
@ -110,5 +111,15 @@ export default defineConfig(({ command, mode }) => {
sourcemap: process.env.NODE_ENV === 'development', sourcemap: process.env.NODE_ENV === 'development',
reportCompressedSize: false, reportCompressedSize: false,
}, },
test: {
environment: 'happy-dom',
deps: {
inline: [
// XXX: misskey-dev/browser-image-resizer has no "type": "module"
'browser-image-resizer',
],
},
},
}; };
}); });

File diff suppressed because it is too large Load diff