提交 0b455cbb 编写于 作者: T Tomas Vik

test(git_service): remove circular dependency and add tests

上级 04f2f86f
......@@ -32,7 +32,7 @@
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceRoot}",
"--extensionTestsPath=${workspaceRoot}/out/test/integration/",
"<paste the last line of `npm run create-test-workspace` output here>"
"<run `npm run create-test-workspace` to generate a test folder>"
],
"preLaunchTask": "${defaultBuildTask}",
"stopOnEntry": false
......
......@@ -43,7 +43,7 @@ We use [`msw`](https://mswjs.io/docs/) to intercept any requests and return prep
### Debugging integration tests
For debugging of the integration tests, we first need to create a test workspace (the `npm run test-integration` task doesn't need this step because it does it automatically). We can do that by running ```npm run create-test-workspace``` script. Then we copy the output to `.vscode/launch.json` instead of the placeholder in the "Integration Tests" launch configuration arguments.
For debugging of the integration tests, we first need to create a test workspace (the `npm run test-integration` task doesn't need this step because it does it automatically). We can do that by running ```npm run create-test-workspace``` script. This script pastes the reference to generated workspace in `.vscode/launch.json`.
Then we can debug the by running the "Integration Tests" [Launch configuration].
......
......@@ -1377,6 +1377,18 @@
"@babel/types": "^7.3.0"
}
},
"@types/bluebird": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.32.tgz",
"integrity": "sha512-dIOxFfI0C+jz89g6lQ+TqhGgPQ0MxSnh/E4xuC0blhFtyW269+mPG5QeLgbdwst/LvdP8o1y0o/Gz5EHXLec/g==",
"dev": true
},
"@types/caseless": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz",
"integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==",
"dev": true
},
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
......@@ -1475,6 +1487,56 @@
"integrity": "sha512-boy4xPNEtiw6N3abRhBi/e7hNvy3Tt8E9ZRAQrwAGzoCGZS/1wjo9KY7JHhnfnEsG5wSjDbymCozUM9a3ea7OQ==",
"dev": true
},
"@types/request": {
"version": "2.48.5",
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.5.tgz",
"integrity": "sha512-/LO7xRVnL3DxJ1WkPGDQrp4VTV1reX9RkC85mJ+Qzykj2Bdw+mG15aAfDahc76HtknjzE16SX/Yddn6MxVbmGQ==",
"dev": true,
"requires": {
"@types/caseless": "*",
"@types/node": "*",
"@types/tough-cookie": "*",
"form-data": "^2.5.0"
},
"dependencies": {
"form-data": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
"integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
}
}
}
},
"@types/request-promise": {
"version": "4.1.46",
"resolved": "https://registry.npmjs.org/@types/request-promise/-/request-promise-4.1.46.tgz",
"integrity": "sha512-3Thpj2Va5m0ji3spaCk8YKrjkZyZc6RqUVOphA0n/Xet66AW/AiOAs5vfXhQIL5NmkaO7Jnun7Nl9NEjJ2zBaw==",
"dev": true,
"requires": {
"@types/bluebird": "*",
"@types/request": "*"
}
},
"@types/sinon": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-9.0.4.tgz",
"integrity": "sha512-sJmb32asJZY6Z2u09bl0G2wglSxDlROlAejCjsnor+LzBMz17gu8IU7vKC/vWDnv9zEq2wqADHVXFjf4eE8Gdw==",
"dev": true,
"requires": {
"@types/sinonjs__fake-timers": "*"
}
},
"@types/sinonjs__fake-timers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz",
"integrity": "sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==",
"dev": true
},
"@types/stack-utils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
......@@ -1490,6 +1552,12 @@
"@types/node": "*"
}
},
"@types/tough-cookie": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz",
"integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==",
"dev": true
},
"@types/vscode": {
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.46.0.tgz",
......
......@@ -232,7 +232,7 @@
"minItems": 1,
"items": {
"type": "object",
"title": "Custom GitLab Quety",
"title": "Custom GitLab Query",
"required": [
"name"
],
......@@ -347,22 +347,22 @@
"createdAfter": {
"type": "string",
"format": "date",
"description": "Return GitLab items created after the given date. It is not applicable for vulnerabilities"
"description": "Return GitLab items created after the given date. ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z. It is not applicable for vulnerabilities"
},
"createdBefore": {
"type": "string",
"format": "date",
"description": "Return GitLab items created before the given date. It is not applicable for vulnerabilities"
"description": "Return GitLab items created before the given date. ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z. It is not applicable for vulnerabilities"
},
"updatedAfter": {
"type": "string",
"format": "date",
"description": "Return GitLab items updated after the given date. It is not applicable for vulnerabilities"
"description": "Return GitLab items updated after the given date. ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z. It is not applicable for vulnerabilities"
},
"updatedBefore": {
"type": "string",
"format": "date",
"description": "Return GitLab items updated before the given date. It is not applicable for vulnerabilities"
"description": "Return GitLab items updated before the given date. ISO 8601 formatted, e.g. 2016-03-11T03:45:40Z. It is not applicable for vulnerabilities"
},
"wip": {
"type": "string",
......@@ -522,6 +522,8 @@
"devDependencies": {
"@types/jest": "^26.0.14",
"@types/node": "^13.7.0",
"@types/request-promise": "^4.1.46",
"@types/sinon": "^9.0.4",
"@types/temp": "^0.8.34",
"@types/vscode": "^1.41.0",
"@typescript-eslint/eslint-plugin": "^3.7.0",
......
#!/usr/bin/env node
// This script creates a temporary workspace that can be used for debugging integration tests
const { readFileSync, writeFileSync } = require('fs');
const path = require('path');
const { default: createTmpWorkspace } = require('../out/create_tmp_workspace');
createTmpWorkspace(false).then(console.log);
const PLACEHOLDER = `<run \`npm run create-test-workspace\` to generate a test folder>`;
createTmpWorkspace(false).then(workspaceFolder => {
const launchPath = path.resolve(__dirname, '../.vscode/launch.json');
const tasksContent = readFileSync(launchPath, 'UTF-8');
const tasks = tasksContent.replace(PLACEHOLDER, workspaceFolder);
writeFileSync(launchPath, tasks);
});
const { StatusCodeError } = require('request-promise/errors');
const { prettyJson, stackToArray } = require('./common');
import { StatusCodeError } from 'request-promise/errors';
import { prettyJson, stackToArray, IDetailedError } from './common';
class ApiError extends Error {
constructor(error, action) {
super(error);
export class ApiError extends Error implements IDetailedError {
originalError: Error;
action: string;
message: string;
constructor(error: Error, action: string) {
super(error.message);
this.action = action;
this.message = `API request failed when trying to ${this.action} because: ${error.message}`;
this.originalError = error;
}
get requestDetails() {
private get requestDetails() {
if (!(this.originalError instanceof StatusCodeError)) return {};
const { method, url } = this.originalError.options;
const { method } = this.originalError.options;
// The url parameter exists, but the types are not complete
// eslint-disable-next-line
// @ts-ignore
const { url } = this.originalError.options;
const { response } = this.originalError;
return {
request: { method, url },
......@@ -19,7 +29,7 @@ class ApiError extends Error {
};
}
get details() {
get details(): string {
const { message, stack } = this;
return prettyJson({
message,
......@@ -28,5 +38,3 @@ class ApiError extends Error {
});
}
}
module.exports = { ApiError };
......@@ -3,9 +3,9 @@ import { prettyJson, stackToArray, IDetailedError } from './common';
export class UserFriendlyError extends Error implements IDetailedError {
originalError: Error;
additionalInfo: string;
additionalInfo?: string;
constructor(message: string, originalError: Error, additionalInfo: string) {
constructor(message: string, originalError: Error, additionalInfo?: string) {
super(message);
this.originalError = originalError;
this.additionalInfo = additionalInfo;
......
......@@ -2,7 +2,6 @@ const vscode = require('vscode');
const request = require('request-promise');
const fs = require('fs');
const { tokenService } = require('./services/token_service');
const statusBar = require('./status_bar');
const { UserFriendlyError } = require('./errors/user_friendly_error');
const { ApiError } = require('./errors/api_error');
const { getCurrentWorkspaceFolder } = require('./services/workspace_service');
......@@ -452,7 +451,6 @@ async function handlePipelineAction(action, workspaceFolder) {
if (pipeline && project) {
let endpoint = `/projects/${project.id}/pipelines/${pipeline.id}/${action}`;
let newPipeline = null;
if (action === 'create') {
const branchName = await createGitService(workspaceFolder).fetchTrackingBranchName();
......@@ -461,16 +459,13 @@ async function handlePipelineAction(action, workspaceFolder) {
try {
const { response } = await fetch(endpoint, 'POST');
newPipeline = response;
return response;
} catch (e) {
handleError(new UserFriendlyError(`Failed to ${action} pipeline.`, e));
}
if (newPipeline) {
statusBar.refreshPipeline();
throw new UserFriendlyError(`Failed to ${action} pipeline.`, e);
}
} else {
vscode.window.showErrorMessage('GitLab Workflow: No project or pipeline found.');
return undefined;
}
}
......
const vscode = require('vscode');
const gitLabService = require('./gitlab_service');
const openers = require('./openers');
const statusBar = require('./status_bar');
const { getCurrentWorkspaceFolderOrSelectOne } = require('./services/workspace_service');
async function showPicker() {
......@@ -33,7 +34,8 @@ async function showPicker() {
return;
}
gitLabService.handlePipelineAction(selected.action, workspaceFolder);
const newPipeline = await gitLabService.handlePipelineAction(selected.action, workspaceFolder);
if (newPipeline) statusBar.refreshPipeline();
}
}
......
......@@ -2,12 +2,7 @@ import * as temp from 'temp';
import simpleGit from 'simple-git';
import * as path from 'path';
import * as fs from 'fs';
import { REMOTE } from './integration/test_infrastructure/constants';
const vsCodeSettings = {
'gitlab.instanceUrl': 'https://test.gitlab.com',
'files.enableTrash': false,
};
import { REMOTE, DEFAULT_VS_CODE_SETTINGS } from './integration/test_infrastructure/constants';
async function createTempFolder(): Promise<string> {
return new Promise<string>((resolve, reject) => {
......@@ -34,6 +29,6 @@ export default async function createTmpWorkspace(autoCleanUp = true): Promise<st
const git = simpleGit(dirPath, { binary: 'git' });
await git.init();
await git.addRemote(REMOTE.NAME, REMOTE.URL);
await addFile(dirPath, '/.vscode/settings.json', JSON.stringify(vsCodeSettings));
await addFile(dirPath, '/.vscode/settings.json', JSON.stringify(DEFAULT_VS_CODE_SETTINGS));
return dirPath;
}
{
"id": 7237201,
"name": "John Doe",
"username": "johndoe",
"state": "active",
"avatar_url": "https://secure.gravatar.com/avatar/6042a9152ada74d",
"web_url": "https://gitlab.com/johndoe"
}
......@@ -2,9 +2,11 @@ const assert = require('assert');
const sinon = require('sinon');
const vscode = require('vscode');
const simpleGit = require('simple-git');
const { graphql } = require('msw');
const { insertSnippet } = require('../../src/commands/insert_snippet');
const { tokenService } = require('../../src/services/token_service');
const getServer = require('./test_infrastructure/mock_server');
const snippetsResponse = require('./fixtures/graphql/snippets.json');
const { getServer, createTextEndpoint } = require('./test_infrastructure/mock_server');
const { GITLAB_URL, REMOTE } = require('./test_infrastructure/constants');
const {
createAndOpenFile,
......@@ -18,7 +20,21 @@ describe('Insert snippet', async () => {
const sandbox = sinon.createSandbox();
before(async () => {
server = getServer();
server = getServer([
createTextEndpoint(
'/projects/278964/snippets/111/files/master/test.js/raw',
'snippet content',
),
createTextEndpoint(
'/projects/278964/snippets/222/files/master/test2.js/raw',
'second blob content',
),
graphql.query('GetSnippets', (req, res, ctx) => {
if (req.variables.projectPath === 'gitlab-org/gitlab')
return res(ctx.data(snippetsResponse));
return res(ctx.data({ project: null }));
}),
]);
await tokenService.setToken(GITLAB_URL, 'abcd-secret');
});
......
......@@ -3,7 +3,9 @@ const sinon = require('sinon');
const vscode = require('vscode');
const statusBar = require('../../src/status_bar');
const { tokenService } = require('../../src/services/token_service');
const getServer = require('./test_infrastructure/mock_server');
const pipelinesResponse = require('./fixtures/rest/pipelines.json');
const pipelineResponse = require('./fixtures/rest/pipeline.json');
const { getServer, createJsonEndpoint } = require('./test_infrastructure/mock_server');
const { GITLAB_URL } = require('./test_infrastructure/constants');
describe('GitLab status bar', () => {
......@@ -18,7 +20,10 @@ describe('GitLab status bar', () => {
};
before(async () => {
server = getServer();
server = getServer([
createJsonEndpoint('/projects/278964/pipelines?ref=master', pipelinesResponse),
createJsonEndpoint('/projects/278964/pipelines/47', pipelineResponse),
]);
await tokenService.setToken(GITLAB_URL, 'abcd-secret');
});
......
......@@ -4,3 +4,7 @@ export const REMOTE = {
NAME: 'origin',
URL: 'git@test.gitlab.com:gitlab-org/gitlab.git',
};
export const DEFAULT_VS_CODE_SETTINGS = {
'gitlab.instanceUrl': GITLAB_URL,
'files.enableTrash': false,
};
const vscode = require('vscode');
import * as vscode from 'vscode';
import { SinonSandbox } from 'sinon';
import * as fs from 'fs';
import * as assert from 'assert';
import { DEFAULT_VS_CODE_SETTINGS } from './constants';
const createAndOpenFile = async testFileUri => {
export const createAndOpenFile = async (testFileUri: vscode.Uri) => {
const createFileEdit = new vscode.WorkspaceEdit();
createFileEdit.createFile(testFileUri);
await vscode.workspace.applyEdit(createFileEdit);
await vscode.window.showTextDocument(testFileUri);
};
const closeAndDeleteFile = async testFileUri => {
export const closeAndDeleteFile = async (testFileUri: vscode.Uri) => {
await vscode.commands.executeCommand('workbench.action.closeActiveEditor');
const edit = new vscode.WorkspaceEdit();
edit.deleteFile(testFileUri);
await vscode.workspace.applyEdit(edit);
};
const simulateQuickPickChoice = (sandbox, nthItem) => {
export const simulateQuickPickChoice = (sandbox: SinonSandbox, nthItem: number) => {
sandbox.stub(vscode.window, 'showQuickPick').callsFake(async options => {
return options[nthItem];
return (await options)[nthItem];
});
};
module.exports = { createAndOpenFile, closeAndDeleteFile, simulateQuickPickChoice };
export const getWorkspaceFoder = () => {
const folders = vscode.workspace.workspaceFolders;
return folders && folders[0]?.uri.fsPath;
};
const { setupServer } = require('msw/node');
const { rest, graphql } = require('msw');
const { rest } = require('msw');
const { API_URL_PREFIX } = require('./constants');
const projectResponse = require('../fixtures/rest/project.json');
const versionResponse = require('../fixtures/rest/version.json');
const openIssueResponse = require('../fixtures/rest/open_issue.json');
const openMergeRequestResponse = require('../fixtures/rest/open_mr.json');
const pipelinesResponse = require('../fixtures/rest/pipelines.json');
const pipelineResponse = require('../fixtures/rest/pipeline.json');
const snippetsResponse = require('../fixtures/graphql/snippets.json');
const createJsonEndpoint = (path, response) =>
rest.get(`${API_URL_PREFIX}${path}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(response));
});
const createQueryJsonEndpoint = (path, queryResponseMap) =>
rest.get(`${API_URL_PREFIX}${path}`, (req, res, ctx) => {
const response = queryResponseMap[req.url.search];
if (!response) {
console.warn(`API call ${req.url.toString()} doesn't have a query handler.`);
return res(ctx.status(404));
}
return res(ctx.status(200), ctx.json(response));
});
const createTextEndpoint = (path, response) =>
rest.get(`${API_URL_PREFIX}${path}`, (req, res, ctx) => {
return res(ctx.status(200), ctx.text(response));
......@@ -21,29 +26,15 @@ const createTextEndpoint = (path, response) =>
const notFoundByDefault = rest.get(/.*/, (req, res, ctx) => res(ctx.status(404)));
module.exports = () => {
const getServer = (handlers = []) => {
const server = setupServer(
createJsonEndpoint('/projects/gitlab-org%2Fgitlab', projectResponse),
createJsonEndpoint('/version', versionResponse),
createJsonEndpoint('/projects/278964/merge_requests?scope=assigned_to_me&state=opened', [
openMergeRequestResponse,
]),
createJsonEndpoint('/projects/278964/issues?scope=assigned_to_me&state=opened', [
openIssueResponse,
]),
createJsonEndpoint('/projects/278964/pipelines?ref=master', pipelinesResponse),
createJsonEndpoint('/projects/278964/pipelines/47', pipelineResponse),
createTextEndpoint('/projects/278964/snippets/111/files/master/test.js/raw', 'snippet content'),
createTextEndpoint(
'/projects/278964/snippets/222/files/master/test2.js/raw',
'second blob content',
),
graphql.query('GetSnippets', (req, res, ctx) => {
if (req.variables.projectPath === 'gitlab-org/gitlab') return res(ctx.data(snippetsResponse));
return res(ctx.data({ project: null }));
}),
...handlers,
notFoundByDefault,
);
server.listen();
return server;
};
module.exports = { getServer, createJsonEndpoint, createQueryJsonEndpoint, createTextEndpoint };
const assert = require('assert');
const vscode = require('vscode');
const IssuableDataProvider = require('../../src/data_providers/issuable').DataProvider;
const { tokenService } = require('../../src/services/token_service');
const getServer = require('./test_infrastructure/mock_server');
const openIssueResponse = require('./fixtures/rest/open_issue.json');
const openMergeRequestResponse = require('./fixtures/rest/open_mr.json');
const userResponse = require('./fixtures/rest/user.json');
const { getServer, createQueryJsonEndpoint } = require('./test_infrastructure/mock_server');
const { GITLAB_URL } = require('./test_infrastructure/constants');
describe('GitLab tree view', () => {
let server;
let dataProvider;
const customQuerySettings = [
{
name: 'Issues assigned to me',
type: 'issues',
scope: 'assigned_to_me',
state: 'opened',
noItemText: 'There is no issue assigned to you.',
},
{
name: 'Merge requests assigned to me',
type: 'merge_requests',
scope: 'assigned_to_me',
state: 'opened',
noItemText: 'There is no MR assigned to you.',
},
{
name: 'Custom GitLab Query for MR',
type: 'merge_requests',
scope: 'assigned_to_me',
state: 'all',
noItemText: 'There is no MR assigned to you.',
maxResults: 30,
labels: ['frontend', 'backend'],
milestone: '13.6',
author: 'johndoe',
assignee: 'johndoe',
search: 'query',
createdBefore: '2020-10-11T03:45:40Z',
createdAfter: '2018-11-01T03:45:40Z',
updatedBefore: '2020-10-30T03:45:40Z',
updatedAfter: '2018-11-01T03:45:40Z',
wip: 'yes',
orderBy: 'updated_at',
sort: 'asc',
},
{
name: 'Custom GitLab Query for issues',
type: 'issues',
scope: 'assigned_to_me',
state: 'opened',
noItemText: 'There is no Issue assigned to you.',
confidential: true,
excludeLabels: ['backstage'],
excludeMilestone: ['13.5'],
excludeAuthor: 'johndoe',
excludeAssignee: 'johndoe',
excludeSearch: 'bug',
excludeSearchIn: 'description',
},
];
before(async () => {
server = getServer();
server = getServer([
createQueryJsonEndpoint('/users', {
'?username=johndoe': [userResponse],
}),
createQueryJsonEndpoint('/projects/278964/merge_requests', {
'?scope=assigned_to_me&state=opened': [openMergeRequestResponse],
'?scope=assigned_to_me&state=all&labels=frontend,backend&milestone=13.6&author_id=7237201&assignee_id=7237201&search=query&created_before=2020-10-11T03&created_after=2018-11-01T03&updated_before=2020-10-30T03&updated_after=2018-11-01T03&wip=yes&order_by=updated_at&sort=asc&per_page=30': [
{ ...openMergeRequestResponse, title: 'Custom Query MR' },
],
}),
createQueryJsonEndpoint('/projects/278964/issues', {
'?scope=assigned_to_me&state=opened': [openIssueResponse],
'?scope=assigned_to_me&state=opened&confidential=true&not[labels]=backstage&not[milestone]=13.5&not[author_username]=johndoe&not[assignee_username]=johndoe&not[search]=bug&not[in]=description': [
{ ...openIssueResponse, title: 'Custom Query Issue' },
],
}),
]);
await tokenService.setToken(GITLAB_URL, 'abcd-secret');
await vscode.workspace.getConfiguration().update('gitlab.customQueries', customQuerySettings);
});
beforeEach(() => {
......@@ -21,6 +93,7 @@ describe('GitLab tree view', () => {
after(async () => {
server.close();
await tokenService.setToken(GITLAB_URL, undefined);
await vscode.workspace.getConfiguration().update('gitlab.customQueries', undefined);
});
/**
......@@ -31,7 +104,7 @@ describe('GitLab tree view', () => {
const [chosenCategory] = categories.filter(c => c.label === label);
assert(
chosenCategory,
`Can't open category ${label} because it's not present in ${categories}`,
`Can't open category ${label} because it's not present in ${categories.map(c => c.label)}`,
);
return await dataProvider.getChildren(chosenCategory);
}
......@@ -55,4 +128,18 @@ describe('GitLab tree view', () => {
'!33824 · Web IDE - remove unused actions (mappings)',
);
});
it('handles full custom query for MR', async () => {
const customMergeRequests = await openCategory('Custom GitLab Query for MR');
assert.strictEqual(customMergeRequests.length, 1);
assert.strictEqual(customMergeRequests[0].label, '!33824 · Custom Query MR');
});
it('handles full custom query for issues', async () => {
const customMergeRequests = await openCategory('Custom GitLab Query for issues');
assert.strictEqual(customMergeRequests.length, 1);
assert.strictEqual(customMergeRequests[0].label, '#219925 · Custom Query Issue');
});
});
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册