MM-16997: Simplified TestUploadFiles (#11707)

* MM-16997: Simplified TestUploadFiles

Tickets:
https://mattermost.atlassian.net/browse/MM-16997
https://mattermost.atlassian.net/browse/MM-16760

- The tests now fully buffer the data vefore uploading it. The prior
  code was much more complex because it was intended to eventually
  mature into generic client code; not a priority, definitely not
  valuable for this test.
- Also improved error handling in TestHookFileWillBeUploaded for
  MM-16760

* PR feedback: typo
Этот коммит содержится в:
Lev
2019-07-26 09:54:04 -07:00
коммит произвёл GitHub
родитель 0a12fef33a
Коммит 52ac87ea32
18 изменённых файлов: 199 добавлений и 305 удалений

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

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"io/ioutil"
"math/rand"
"mime/multipart"
"net/http"
"net/textproto"
@@ -22,6 +23,7 @@ import (
"github.com/mattermost/mattermost-server/model"
"github.com/mattermost/mattermost-server/utils/fileutils"
"github.com/mattermost/mattermost-server/utils/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,36 +33,6 @@ func init() {
testDir, _ = fileutils.FindDir("tests")
}
func checkCond(tb testing.TB, cond bool, text string) {
if !cond {
tb.Error(text)
}
}
func checkEq(tb testing.TB, v1, v2 interface{}, text string) {
checkCond(tb, fmt.Sprintf("%+v", v1) == fmt.Sprintf("%+v", v2), text)
}
func checkNeq(tb testing.TB, v1, v2 interface{}, text string) {
checkCond(tb, fmt.Sprintf("%+v", v1) != fmt.Sprintf("%+v", v2), text)
}
type zeroReader struct {
limit, read int
}
func (z *zeroReader) Read(b []byte) (int, error) {
for i := range b {
if z.read == z.limit {
return i, io.EOF
}
b[i] = 0
z.read++
}
return len(b), nil
}
// File Section
var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
@@ -68,208 +40,92 @@ func escapeQuotes(s string) string {
return quoteEscaper.Replace(s)
}
type UploadOpener func() (io.ReadCloser, int64, error)
func NewUploadOpenerReader(in io.Reader) UploadOpener {
return func() (io.ReadCloser, int64, error) {
rc, ok := in.(io.ReadCloser)
if ok {
return rc, -1, nil
} else {
return ioutil.NopCloser(in), -1, nil
}
}
}
func NewUploadOpenerFile(path string) UploadOpener {
return func() (io.ReadCloser, int64, error) {
fi, err := os.Stat(path)
func randomBytes(n int) []byte {
bb := make([]byte, n)
_, err := rand.Read(bb)
if err != nil {
return nil, 0, err
panic(err.Error())
}
return bb
}
func fileBytes(path string) []byte {
path = filepath.Join(testDir, path)
f, err := os.Open(path)
if err != nil {
return nil, 0, err
panic(err.Error())
}
return f, fi.Size(), nil
defer f.Close()
bb, err := ioutil.ReadAll(f)
if err != nil {
panic(err.Error())
}
return bb
}
// testUploadFile and testUploadFiles have been "staged" here, eventually they
// should move back to being model.Client4 methods, once the specifics of the
// public API are sorted out.
func testUploadFile(c *model.Client4, url string, body io.Reader, contentType string,
func testDoUploadFileRequest(t testing.TB, c *model.Client4, url string, blob []byte, contentType string,
contentLength int64) (*model.FileUploadResponse, *model.Response) {
rq, _ := http.NewRequest("POST", c.ApiUrl+c.GetFilesRoute()+url, body)
req, err := http.NewRequest("POST", c.ApiUrl+c.GetFilesRoute()+url, bytes.NewReader(blob))
require.Nil(t, err)
if contentLength != 0 {
rq.ContentLength = contentLength
req.ContentLength = contentLength
}
rq.Header.Set("Content-Type", contentType)
req.Header.Set("Content-Type", contentType)
if len(c.AuthToken) > 0 {
rq.Header.Set(model.HEADER_AUTH, c.AuthType+" "+c.AuthToken)
req.Header.Set(model.HEADER_AUTH, c.AuthType+" "+c.AuthToken)
}
rp, err := c.HttpClient.Do(rq)
if err != nil || rp == nil {
return nil, model.BuildErrorResponse(rp, model.NewAppError(url, "model.client.connecting.app_error", nil, err.Error(), 0))
}
defer closeBody(rp)
resp, err := c.HttpClient.Do(req)
require.Nil(t, err)
require.NotNil(t, resp)
defer closeBody(resp)
if rp.StatusCode >= 300 {
return nil, model.BuildErrorResponse(rp, model.AppErrorFromJson(rp.Body))
if resp.StatusCode >= 300 {
return nil, model.BuildErrorResponse(resp, model.AppErrorFromJson(resp.Body))
}
return model.FileUploadResponseFromJson(rp.Body), model.BuildResponse(rp)
return model.FileUploadResponseFromJson(resp.Body), model.BuildResponse(resp)
}
func testUploadFiles(
func testUploadFilesPost(
t testing.TB,
c *model.Client4,
channelId string,
names []string,
openers []UploadOpener,
contentLengths []int64,
blobs [][]byte,
clientIds []string,
useMultipart,
useChunkedInSimplePost bool,
) (
fileUploadResponse *model.FileUploadResponse,
response *model.Response,
) {
useChunked bool,
) (*model.FileUploadResponse, *model.Response) {
// Do not check len(clientIds), leave it entirely to the user to
// provide. The server will error out if it does not match the number
// of files, but it's not critical here.
if len(names) == 0 || len(openers) == 0 || len(names) != len(openers) {
return nil, &model.Response{
Error: model.NewAppError("testUploadFiles",
"model.client.upload_post_attachment.file.app_error",
nil, "Empty or mismatched file data", http.StatusBadRequest),
}
}
require.NotEmpty(t, names)
require.NotEmpty(t, blobs)
require.Equal(t, len(names), len(blobs))
// emergencyResponse is a convenience wrapper to return an error response
emergencyResponse := func(err error, errCode string) *model.Response {
return &model.Response{
Error: model.NewAppError("testUploadFiles",
"model.client."+errCode+".app_error",
nil, err.Error(), http.StatusBadRequest),
}
}
// For multipart, start writing the request as a goroutine, and pipe
// multipart.Writer into it, otherwise generate a new request each
// time.
pipeReader, pipeWriter := io.Pipe()
mw := multipart.NewWriter(pipeWriter)
if useMultipart {
fileUploadResponseChannel := make(chan *model.FileUploadResponse)
responseChannel := make(chan *model.Response)
closedMultipart := false
go func() {
fur, resp := testUploadFile(c, "", pipeReader, mw.FormDataContentType(), -1)
responseChannel <- resp
fileUploadResponseChannel <- fur
}()
defer func() {
for {
select {
// Premature response, before the entire
// multipart was sent
case response = <-responseChannel:
// Guaranteed to be there
fileUploadResponse = <-fileUploadResponseChannel
if !closedMultipart {
_ = mw.Close()
_ = pipeWriter.Close()
closedMultipart = true
}
return
// Normal response, after the multipart was sent.
default:
if !closedMultipart {
err := mw.Close()
if err != nil {
fileUploadResponse = nil
response = emergencyResponse(err, "upload_post_attachment.writer")
return
}
err = pipeWriter.Close()
if err != nil {
fileUploadResponse = nil
response = emergencyResponse(err, "upload_post_attachment.writer")
return
}
closedMultipart = true
}
}
}
}()
err := mw.WriteField("channel_id", channelId)
if err != nil {
return nil, emergencyResponse(err, "upload_post_attachment.channel_id")
}
} else {
fileUploadResponse = &model.FileUploadResponse{}
}
data := make([]byte, 512)
upload := func(i int, f io.ReadCloser) *model.Response {
fileUploadResponse := &model.FileUploadResponse{}
for i, blob := range blobs {
var cl int64
defer f.Close()
if len(contentLengths) > i {
cl = contentLengths[i]
}
n, err := f.Read(data)
if err != nil && err != io.EOF {
return emergencyResponse(err, "upload_post_attachment")
}
ct := http.DetectContentType(data[:n])
reader := io.MultiReader(bytes.NewReader(data[:n]), f)
if useMultipart {
if len(clientIds) > i {
err := mw.WriteField("client_ids", clientIds[i])
if err != nil {
return emergencyResponse(err, "upload_post_attachment.file")
}
}
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="files"; filename="%s"`, escapeQuotes(names[i])))
h.Set("Content-Type", ct)
// If we error here, writing to mw, the deferred handler
part, err := mw.CreatePart(h)
if err != nil {
return emergencyResponse(err, "upload_post_attachment.writer")
}
_, err = io.Copy(part, reader)
if err != nil {
return emergencyResponse(err, "upload_post_attachment.writer")
}
if useChunked {
cl = -1
} else {
cl = int64(len(blob))
}
ct := http.DetectContentType(blob)
postURL := fmt.Sprintf("?channel_id=%v", url.QueryEscape(channelId)) +
fmt.Sprintf("&filename=%v", url.QueryEscape(names[i]))
if len(clientIds) > i {
postURL += fmt.Sprintf("&client_id=%v", url.QueryEscape(clientIds[i]))
}
if useChunkedInSimplePost {
cl = -1
}
fur, resp := testUploadFile(c, postURL, reader, ct, cl)
fur, resp := testDoUploadFileRequest(t, c, postURL, blob, ct, cl)
if resp.Error != nil {
return resp
return nil, resp
}
fileUploadResponse.FileInfos = append(fileUploadResponse.FileInfos, fur.FileInfos[0])
if len(clientIds) > 0 {
if len(fur.ClientIds) > 0 {
@@ -278,28 +134,57 @@ func testUploadFiles(
fileUploadResponse.ClientIds = append(fileUploadResponse.ClientIds, "")
}
}
response = resp
}
return nil
return fileUploadResponse, nil
}
for i, open := range openers {
f, _, err := open()
if err != nil {
return nil, emergencyResponse(err, "upload_post_attachment")
func testUploadFilesMultipart(
t testing.TB,
c *model.Client4,
channelId string,
names []string,
blobs [][]byte,
clientIds []string,
) (
fileUploadResponse *model.FileUploadResponse,
response *model.Response,
) {
// Do not check len(clientIds), leave it entirely to the user to
// provide. The server will error out if it does not match the number
// of files, but it's not critical here.
require.NotEmpty(t, names)
require.NotEmpty(t, blobs)
require.Equal(t, len(names), len(blobs))
mwBody := &bytes.Buffer{}
mw := multipart.NewWriter(mwBody)
err := mw.WriteField("channel_id", channelId)
require.Nil(t, err)
for i, blob := range blobs {
ct := http.DetectContentType(blob)
if len(clientIds) > i {
err = mw.WriteField("client_ids", clientIds[i])
require.Nil(t, err)
}
resp := upload(i, f)
if resp != nil && resp.Error != nil {
return nil, resp
}
h := textproto.MIMEHeader{}
h.Set("Content-Disposition",
fmt.Sprintf(`form-data; name="files"; filename="%s"`, escapeQuotes(names[i])))
h.Set("Content-Type", ct)
// If we error here, writing to mw, the deferred handler
part, err := mw.CreatePart(h)
require.Nil(t, err)
_, err = io.Copy(part, bytes.NewReader(blob))
require.Nil(t, err)
}
// In case of a simple POST, the return values have been set by upload(),
// otherwise we finished writing the multipart, and the return values will
// be set in defer
return fileUploadResponse, response
mw.Close()
return testDoUploadFileRequest(t, c, "", mwBody.Bytes(), mw.FormDataContentType(), -1)
}
func TestUploadFiles(t *testing.T) {
@@ -315,14 +200,10 @@ func TestUploadFiles(t *testing.T) {
// Get better error messages
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableDeveloper = true })
op := func(name string) UploadOpener {
return NewUploadOpenerFile(filepath.Join(testDir, name))
}
tests := []struct {
title string
client *model.Client4
openers []UploadOpener
blobs [][]byte
names []string
clientIds []string
@@ -370,7 +251,7 @@ func TestUploadFiles(t *testing.T) {
{
title: "Happy invalid image",
names: []string{"testgif.gif"},
openers: []UploadOpener{NewUploadOpenerFile(filepath.Join(testDir, "test-search.md"))},
blobs: [][]byte{fileBytes("test-search.md")},
skipPayloadValidation: true,
expectedCreatorId: th.BasicUser.Id,
},
@@ -500,11 +381,11 @@ func TestUploadFiles(t *testing.T) {
title: "Happy stream",
useChunkedInSimplePost: true,
skipPayloadValidation: true,
names: []string{"50Mb-stream"},
openers: []UploadOpener{NewUploadOpenerReader(&zeroReader{limit: 50 * 1024 * 1024})},
names: []string{"1Mb-stream"},
blobs: [][]byte{randomBytes(1024 * 1024)},
setupConfig: func(a *app.App) func(a *app.App) {
maxFileSize := *a.Config().FileSettings.MaxFileSize
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 50 * 1024 * 1024 })
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 1024 * 1024 })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize })
}
@@ -598,13 +479,13 @@ func TestUploadFiles(t *testing.T) {
{
title: "Error stream too large",
skipPayloadValidation: true,
names: []string{"50Mb-stream"},
openers: []UploadOpener{NewUploadOpenerReader(&zeroReader{limit: 50 * 1024 * 1024})},
names: []string{"1Mb-stream"},
blobs: [][]byte{randomBytes(1024 * 1024)},
skipSuccessValidation: true,
checkResponse: CheckRequestEntityTooLargeStatus,
setupConfig: func(a *app.App) func(a *app.App) {
maxFileSize := *a.Config().FileSettings.MaxFileSize
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 100 * 1024 })
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = 10 * 1024 })
return func(a *app.App) {
a.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.MaxFileSize = maxFileSize })
}
@@ -618,19 +499,6 @@ func TestUploadFiles(t *testing.T) {
continue
}
// Set the default values and title
client := th.Client
if tc.client != nil {
client = tc.client
}
channelId := channel.Id
if tc.channelId != "" {
channelId = tc.channelId
}
if tc.checkResponse == nil {
tc.checkResponse = CheckNoError
}
title := ""
if useMultipart {
title = "multipart "
@@ -642,22 +510,47 @@ func TestUploadFiles(t *testing.T) {
}
title += fmt.Sprintf("%v", tc.names)
t.Run(title, func(t *testing.T) {
// Apply any necessary config changes
var restoreConfig func(a *app.App)
if tc.setupConfig != nil {
restoreConfig = tc.setupConfig(th.App)
restoreConfig := tc.setupConfig(th.App)
if restoreConfig != nil {
defer restoreConfig(th.App)
}
}
t.Run(title, func(t *testing.T) {
if len(tc.openers) == 0 {
// Set the default values
client := th.Client
if tc.client != nil {
client = tc.client
}
channelId := channel.Id
if tc.channelId != "" {
channelId = tc.channelId
}
blobs := tc.blobs
if len(blobs) == 0 {
for _, name := range tc.names {
tc.openers = append(tc.openers, op(name))
blobs = append(blobs, fileBytes(name))
}
}
fileResp, resp := testUploadFiles(client, channelId, tc.names,
tc.openers, nil, tc.clientIds, useMultipart,
tc.useChunkedInSimplePost)
var fileResp *model.FileUploadResponse
var resp *model.Response
if useMultipart {
fileResp, resp = testUploadFilesMultipart(t, client, channelId, tc.names, blobs, tc.clientIds)
} else {
fileResp, resp = testUploadFilesPost(t, client, channelId, tc.names, blobs, tc.clientIds, tc.useChunkedInSimplePost)
}
if tc.checkResponse != nil {
tc.checkResponse(t, resp)
} else {
if resp != nil {
require.Nil(t, resp.Error)
}
}
if tc.skipSuccessValidation {
return
}
@@ -668,68 +561,63 @@ func TestUploadFiles(t *testing.T) {
for i, ri := range fileResp.FileInfos {
// The returned file info from the upload call will be missing some fields that will be stored in the database
checkEq(t, ri.CreatorId, tc.expectedCreatorId, "File should be assigned to user")
checkEq(t, ri.PostId, "", "File shouldn't have a post Id")
checkEq(t, ri.Path, "", "File path should not be set on returned info")
checkEq(t, ri.ThumbnailPath, "", "File thumbnail path should not be set on returned info")
checkEq(t, ri.PreviewPath, "", "File preview path should not be set on returned info")
assert.Equal(t, ri.CreatorId, tc.expectedCreatorId, "File should be assigned to user")
assert.Equal(t, ri.PostId, "", "File shouldn't have a post Id")
assert.Equal(t, ri.Path, "", "File path should not be set on returned info")
assert.Equal(t, ri.ThumbnailPath, "", "File thumbnail path should not be set on returned info")
assert.Equal(t, ri.PreviewPath, "", "File preview path should not be set on returned info")
if len(tc.clientIds) > i {
checkCond(t, len(fileResp.ClientIds) == len(tc.clientIds),
assert.True(t, len(fileResp.ClientIds) == len(tc.clientIds),
fmt.Sprintf("Wrong number of clientIds returned, expected %v, got %v", len(tc.clientIds), len(fileResp.ClientIds)))
checkEq(t, fileResp.ClientIds[i], tc.clientIds[i],
assert.Equal(t, fileResp.ClientIds[i], tc.clientIds[i],
fmt.Sprintf("Wrong clientId returned, expected %v, got %v", tc.clientIds[i], fileResp.ClientIds[i]))
}
dbInfo, err := th.App.Srv.Store.FileInfo().Get(ri.Id)
require.Nil(t, err)
checkEq(t, dbInfo.Id, ri.Id, "File id from response should match one stored in database")
checkEq(t, dbInfo.CreatorId, tc.expectedCreatorId, "F ile should be assigned to user")
checkEq(t, dbInfo.PostId, "", "File shouldn't have a post")
checkNeq(t, dbInfo.Path, "", "File path should be set in database")
assert.Equal(t, dbInfo.Id, ri.Id, "File id from response should match one stored in database")
assert.Equal(t, dbInfo.CreatorId, tc.expectedCreatorId, "F ile should be assigned to user")
assert.Equal(t, dbInfo.PostId, "", "File shouldn't have a post")
assert.NotEqual(t, dbInfo.Path, "", "File path should be set in database")
_, fname := filepath.Split(dbInfo.Path)
ext := filepath.Ext(fname)
name := fname[:len(fname)-len(ext)]
expectedDir := fmt.Sprintf("%v/teams/%v/channels/%v/users/%s/%s", date, FILE_TEAM_ID, channel.Id, ri.CreatorId, ri.Id)
expectedPath := fmt.Sprintf("%s/%s", expectedDir, fname)
checkEq(t, dbInfo.Path, expectedPath,
assert.Equal(t, dbInfo.Path, expectedPath,
fmt.Sprintf("File %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.Path, expectedPath))
if tc.expectImage {
expectedThumbnailPath := fmt.Sprintf("%s/%s_thumb.jpg", expectedDir, name)
expectedPreviewPath := fmt.Sprintf("%s/%s_preview.jpg", expectedDir, name)
checkEq(t, dbInfo.ThumbnailPath, expectedThumbnailPath,
assert.Equal(t, dbInfo.ThumbnailPath, expectedThumbnailPath,
fmt.Sprintf("Thumbnail for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.ThumbnailPath, expectedThumbnailPath))
checkEq(t, dbInfo.PreviewPath, expectedPreviewPath,
assert.Equal(t, dbInfo.PreviewPath, expectedPreviewPath,
fmt.Sprintf("Preview for %v saved to:%q, expected:%q", dbInfo.Name, dbInfo.PreviewPath, expectedPreviewPath))
checkCond(t,
assert.True(t,
dbInfo.HasPreviewImage == tc.expectedImageHasPreview[i],
fmt.Sprintf("Image: HasPreviewImage should be set for %s", dbInfo.Name))
checkCond(t,
assert.True(t,
dbInfo.Width == tc.expectedImageWidths[i] && dbInfo.Height == tc.expectedImageHeights[i],
fmt.Sprintf("Image dimensions: expected %dwx%dh, got %vwx%dh",
tc.expectedImageWidths[i], tc.expectedImageHeights[i],
dbInfo.Width, dbInfo.Height))
}
/*if !tc.skipPayloadValidation {
if !tc.skipPayloadValidation {
compare := func(get func(string) ([]byte, *model.Response), name string) {
data, resp := get(ri.Id)
if resp.Error != nil {
t.Fatal(resp.Error)
}
require.NotNil(t, resp)
require.Nil(t, resp.Error)
expected, err := ioutil.ReadFile(filepath.Join(testDir, name))
if err != nil {
t.Fatal(err)
}
require.Nil(t, err)
if bytes.Compare(data, expected) != 0 {
tf, err := ioutil.TempFile("", fmt.Sprintf("test_%v_*_%s", i, name))
if err != nil {
t.Fatal(err)
}
io.Copy(tf, bytes.NewReader(data))
require.Nil(t, err)
_, _ = io.Copy(tf, bytes.NewReader(data))
tf.Close()
t.Errorf("Actual data mismatched %s, written to %q", name, tf.Name())
}
@@ -745,15 +633,12 @@ func TestUploadFiles(t *testing.T) {
if len(tc.expectedImageThumbnailNames) > i {
compare(client.GetFilePreview, tc.expectedImagePreviewNames[i])
}
}*/
}
th.cleanupTestFile(dbInfo)
}
})
if restoreConfig != nil {
restoreConfig(th.App)
}
}
}
}

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

@@ -497,6 +497,7 @@ func TestHookFileWillBeUploaded(t *testing.T) {
package main
import (
"fmt"
"io"
"github.com/mattermost/mattermost-server/plugin"
"github.com/mattermost/mattermost-server/model"
@@ -507,7 +508,10 @@ func TestHookFileWillBeUploaded(t *testing.T) {
}
func (p *MyPlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
output.Write([]byte("ignored"))
n, err := output.Write([]byte("ignored"))
if err != nil {
return info, fmt.Sprintf("FAILED to write output file n: %v, err: %v", n, err)
}
info.Name = "ignored"
return info, "rejected"
}
@@ -607,6 +611,7 @@ func TestHookFileWillBeUploaded(t *testing.T) {
import (
"io"
"bytes"
"fmt"
"github.com/mattermost/mattermost-server/plugin"
"github.com/mattermost/mattermost-server/model"
)
@@ -616,13 +621,17 @@ func TestHookFileWillBeUploaded(t *testing.T) {
}
func (p *MyPlugin) FileWillBeUploaded(c *plugin.Context, info *model.FileInfo, file io.Reader, output io.Writer) (*model.FileInfo, string) {
p.API.LogDebug(info.Name)
var buf bytes.Buffer
buf.ReadFrom(file)
p.API.LogDebug(buf.String())
n, err := buf.ReadFrom(file)
if err != nil {
return info, fmt.Sprintf("FAILED to read input file n: %v, err: %v", n, err)
}
outbuf := bytes.NewBufferString("changedtext")
io.Copy(output, outbuf)
n, err = io.Copy(output, outbuf)
if int(n) != len("changedtext") || err != nil {
return info, fmt.Sprintf("FAILED to write output file n: %v, err: %v", n, err)
}
info.Name = "modifiedinfo"
return info, ""
}

Двоичные данные
tests/orientation_test_1_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 300 KiB

После

Ширина:  |  Высота:  |  Размер: 300 KiB

Двоичные данные
tests/orientation_test_1_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_2_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 300 KiB

После

Ширина:  |  Высота:  |  Размер: 300 KiB

Двоичные данные
tests/orientation_test_2_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_3_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 302 KiB

После

Ширина:  |  Высота:  |  Размер: 302 KiB

Двоичные данные
tests/orientation_test_3_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_4_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 301 KiB

После

Ширина:  |  Высота:  |  Размер: 302 KiB

Двоичные данные
tests/orientation_test_4_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_5_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 300 KiB

После

Ширина:  |  Высота:  |  Размер: 300 KiB

Двоичные данные
tests/orientation_test_5_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_6_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 301 KiB

После

Ширина:  |  Высота:  |  Размер: 301 KiB

Двоичные данные
tests/orientation_test_6_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_7_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 301 KiB

После

Ширина:  |  Высота:  |  Размер: 302 KiB

Двоичные данные
tests/orientation_test_7_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB

Двоичные данные
tests/orientation_test_8_expected_preview.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 301 KiB

После

Ширина:  |  Высота:  |  Размер: 301 KiB

Двоичные данные
tests/orientation_test_8_expected_thumb.jpeg

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 7.8 KiB

После

Ширина:  |  Высота:  |  Размер: 7.8 KiB